Skip to main content
ProofBridge’s correctness story rests on one small, load-bearing fact: the on-chain order digest is the same byte string on EVM and on Stellar. That property is what lets a proof that binds an orderHash on one chain be verified on the other. To hold, both sides have to hash the exact same struct in the exact same way — and they do.

One tuple, two chains

There is a single canonical 15-field Order tuple. Both chains build it from their own direction-specific OrderParams plus the local context they have (chain id, contract address) before hashing. The digest is equal across chains by construction, not by parallel maintenance of two hashers that happen to match.
SlotFieldType
1orderChainTokenbytes32
2adChainTokenbytes32
3amountuint256 (order-chain raw units)
4bridgerbytes32
5orderChainIduint256
6orderPortalbytes32
7orderRecipientbytes32
8adChainIduint256
9adManagerbytes32
10adIdstring (hashed to bytes32 via keccak256)
11adCreatorbytes32
12adRecipientbytes32
13saltuint256
14orderDecimalsuint8 (padded to 32 bytes)
15adDecimalsuint8 (padded to 32 bytes)

Typehash

Order(
  bytes32 orderChainToken, bytes32 adChainToken, uint256 amount,
  bytes32 bridger,
  uint256 orderChainId, bytes32 orderPortal, bytes32 orderRecipient,
  uint256 adChainId, bytes32 adManager,
  string adId, bytes32 adCreator, bytes32 adRecipient,
  uint256 salt,
  uint8 orderDecimals, uint8 adDecimals
)
adId participates in the hash as a string member per EIP-712 — it’s hashed with keccak256 before being concatenated into the struct hash. orderDecimals and adDecimals are the two trailing scaling fields described in Decimal scaling.

Minimal domain, on purpose

The EIP-712 domain is intentionally minimal:
{ "name": "ProofBridge", "version": "1" }
No chainId and no verifyingContract in the domain. Chain ids and contract addresses live inside the struct instead (slots 5–6, 8–9), which means the exact same digest is accepted on either chain without a domain-separator mismatch. A chain-specific verifyingContract would split the digest in two and break the cross-chain invariant. Because chain ids and contracts live inside the struct, they’re still signed — a replay to the wrong chain or wrong portal fails at validateOrder, not at signature recovery.

How the two sides build it

  • OrderPortal (order chain) takes its OrderParams and fills in the local orderChainId + orderPortal from block.chainid and address(this). The remaining fields — notably adChainId, adManager, adCreator, adRecipient — come from the signed payload.
  • AdManager (ad chain) does the mirror image: fills in adChainId and adManager from its own local context, trusts the signed orderChainId, orderPortal, bridger, orderRecipient from the payload.
Both converge on the same 15-slot tuple. On EVM the implementation is the OrderHash library (contracts/evm/src/libraries/OrderHash.sol); on Stellar it’s proofbridge-core::eip712. See Off-chain signing for the JS/TS recipe that reproduces the digest bit-for-bit off-chain.

Why this matters for proofs

The zk circuit’s public inputs include the order hash modulo the BN254 prime — [nullifier, orderHash % p, targetRoot, sideFlag]. If the two chains computed different digests for “the same” order, the public inputs would diverge and no single proof could satisfy both sides. The cross-chain digest invariant is what makes a single proof usable on both chains, which is what makes atomic settlement possible without a trusted relayer.