The naive version (and why it fails)
The obvious dual-anchor implementation is two API calls inside a try/catch:
// THE NAIVE WAY — broken in three ways
async function dualAnchor(record) {
const root = computeMerkleRoot(record);
await fabric.commit(root); // hopefully succeeds
await polygon.commit(root); // hopefully also succeeds
record.status = 'ANCHORED';
}
It fails three ways:
- Atomicity: If Fabric commits and Polygon throws, the record is on one chain only. What's
record.status? Anchored? Failed? Half? - Retry policy: Re-running the function commits to Fabric a second time. Now you have duplicate batches.
- Verification ambiguity: A verifier hits
/verifyand sees "anchored." Where? On both? On one? Which one?
The actual implementation needs to answer those three questions explicitly.
Design principle 1: one Merkle root, computed once
The whole point of Hybrid is that the same proof verifies on both chains. So the Merkle root is computed once, before any chain submit, and held constant for the duration of the batch.
// Batch worker, simplified
const records = await redis.lpopN('queue', BATCH_SIZE); // atomic
const leaves = records.map(r => r.hash);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
const root = tree.getHexRoot();
// root is now FROZEN for this batch — both chains get this exact value
Sorted-pair Keccak-256 is the key choice. It makes the proof chain-agnostic — a verifier reconstructs the root the same way whether they're checking Polygon or Fabric. Both chains store the identical bytes.
Design principle 2: primary commits before secondary — and stands alone
Hybrid customers choose a primary chain (default: Fabric, because data sovereignty is usually the harder constraint). The worker submits to the primary first. If primary succeeds, the batch is valid even if secondary fails.
// 1. Submit to primary chain. If this fails, abort the whole batch.
try {
const primaryReceipt = await primary.submit(batchId, root);
writePrimaryFieldsToDB(records, primaryReceipt);
} catch (err) {
// Records go back on the queue; primary will retry next tick.
await redis.rpush('queue', ...records);
throw err;
}
// 2. Submit to secondary chain. If this fails, primary still stands.
try {
const secondaryReceipt = await secondary.submit(batchId, root);
writeDualAnchorFieldsToDB(records, secondaryReceipt);
} catch (err) {
// Mark records as pending-secondary. DualAnchorRetry will pick them up.
markSecondaryPending(records, { error: err.message, attempts: 0 });
}
From the customer's perspective, a Hybrid record is "anchored" the moment the primary chain confirms. The secondary is a strict bonus — if it lands within the batch, great; if not, the retry service back-fills it.
Design principle 3: retry is idempotent and bounded
The retry service wakes up hourly, queries for records with secondaryStatus: pending, and re-attempts the secondary commit. Two non-obvious things make this safe:
- The batch and its Merkle root are immutable. Retry doesn't recompute anything — it just re-submits the same
(batchId, root)tuple. Both chains' contracts are idempotent: writing the same batchId twice is a no-op (or a revert), not a corruption. - Attempts are bounded. Max 5 attempts. After 5 failures,
secondaryStatus: failedand an alert fires. We don't retry forever — the chain might be permanently misconfigured, and silent retries hide real problems.
async function retrySweep() {
const stuck = await Record.find({
secondaryStatus: 'pending',
secondaryAttempts: { $lt: MAX_ATTEMPTS }
});
for (const record of stuck) {
try {
const receipt = await secondary.submit(record.batchId, record.merkleRoot);
writeDualAnchorFieldsToDB([record], receipt);
deliverWebhook(record); // now includes dualAnchor
} catch (err) {
record.secondaryAttempts++;
if (record.secondaryAttempts >= MAX_ATTEMPTS) {
record.secondaryStatus = 'failed';
alertOps({ recordId: record.id, error: err.message });
}
await record.save();
}
}
}
Design principle 4: verification returns independent verdicts
The hardest user-facing question: what does /v1/verify say when the record is anchored on primary but not secondary yet?
It can't return verified: true — that would be lying about Hybrid's two-chain promise. It can't return verified: false — that means tampered, which isn't true either. Returning a single boolean is the wrong abstraction.
So we return two verdicts:
{
"success": true,
"verified": true, // primary chain verdict
"chainType": "fabric",
"onChainMerkleRoot": "0xe90e818...",
"dualAnchor": {
"verified": null, // secondary verdict
"chainType": "evm",
"reason": "batch-not-found" // why it's null
}
}
The four states of (verified, dualAnchor.verified) map cleanly to operational decisions:
| primary | secondary | What it means | What to do |
|---|---|---|---|
true | true | Both chains agree | Trust the data |
true | null | Secondary pending or reader unavailable | Wait or retry — not tampering |
true | false | Chains disagree on root | Investigate — one chain may be tampered or out of sync |
false | any | Primary doesn't match | Data tampered, regardless of secondary |
null is a transient state (retry pending) — false is a permanent claim about tampering. Conflating them turns every RPC blip into a false-positive security alert.
Why "atomic batch boundary" is the actual promise
People hear "dual anchor" and assume some two-phase commit ceremony. There isn't one — cross-chain atomic commit is impossible in the general case (different finality models, different trust assumptions). What we promise instead is weaker but useful:
- Within one batch, both chains are attempted with the same Merkle root
- A record is never anchored to only the secondary chain — if primary fails, secondary is skipped
- If primary succeeds and secondary fails, the system converges (secondary back-fills within ~1 hour, up to 5 attempts)
This is enough for the verifiability model to hold: every Hybrid record either has both anchors or is in the process of getting the secondary anchor. There is no terminal "primary-only forever" state unless something is permanently broken (and then ops gets alerted, not the customer).
The webhook payload
Hybrid webhooks carry both anchors in one delivery. Primary fields at the top; secondary inside dualAnchor. Old EVM-only receivers that don't know about dualAnchor still parse the top-level fields correctly — the design is strictly additive:
{
"type": "ANCHORED",
"hash": "e90e818fbf...",
"chainType": "fabric",
"networkId": "fabric-byo",
"batchId": "4",
"transactionHash": "aea1e2167553e7e3...",
"blockNumber": null,
"dualAnchor": {
"chainType": "evm",
"networkId": "polygon-amoy",
"batchId": 114,
"blockNumber": 38228377,
"transactionHash": "0x6bc51454f711..."
}
}
If the secondary lands later (after a retry), Anchora delivers a fresh webhook with the complete dualAnchor sub-object. Idempotency on your side: keep your handler keyed on hash + recordId, not on first-delivery.
What we got wrong the first time
We shipped Hybrid v0 with a single boolean verified. Customers immediately asked: "if both chains say verified, do you OR them or AND them?" Either answer is wrong. AND'ing means transient secondary failures register as tampering. OR'ing means tampered secondary data goes undetected. The only correct answer was to expose both verdicts.
So we shipped v1 with the dualAnchor sub-object. Now the verification logic is in the verifier, not in our boolean — banks AND both verdicts (strict), consumer apps OR them (lenient), and either choice is defensible because the underlying data isn't lossy.
What's still ahead
The current Hybrid only supports one primary + one secondary. Real consortium workflows want N chains with M-of-N verification policies ("Fabric + Polygon + Ethereum L2, where any 2 of 3 verified = trust"). That's Consortium Mode — on the roadmap for Q3 2026. The dual-anchor pattern is the foundation.
Try Hybrid mode
The setup takes about 20 minutes if you already have Fabric. The verification benefits last forever.
Read the Hybrid guide