The destination is inside what is signed, so a signature cannot be replayed on another chain
Solidus Bridge copies the status of a credential to other chains, and a contract on each chain accepts a status only when enough validators signed it. What a credential status bridge is covers the whole path. Check a condition without seeing the data covers what a contract does with an applied status. This page covers one rule on the way in: a signature is good for one destination and no other.
Replay, in one example
A replay is a real, valid message sent again where it should not count. The signature is genuine. The place or the moment is wrong.
Say the validators sign message 41 for Sepolia. Someone copies it, with its signatures, to a Fuji contract that also expects a message 41. If the signature covered only the message bytes, Fuji would see valid signatures from signers it trusts and accept the copy. Nothing was forged. The signers never meant Fuji.
The defence is to put the destination into what the validators sign.
What is inside the signed bytes
Validators do not sign the message itself. They sign a digest of a fixed tag and five values. This table is read from AttestationLib.digest and SolidusCredentialMirror.submit.
| Field | Width | Where the mirror gets it |
|---|---|---|
Tag SOLIDUS_BRIDGE_ATTESTATION_V1 |
29 bytes | A constant in the library |
| Solidus chain id | 8 bytes | Stored in the mirror at deployment |
| Destination domain | 4 bytes | Stored in the mirror at deployment, as localDomain |
| Sequence number | 8 bytes | The message header |
| Message id | 32 bytes | The keccak256 hash of the whole message |
| Solidus height | 8 bytes | The message header |
The values are packed one after another, 89 bytes in all, and hashed with keccak256. The result is wrapped in the standard Ethereum signed-message prefix, hashed again and signed. Each signature is 65 bytes.
The message id hashes the whole body, so the body cannot be swapped under the signatures. The 18-byte message header (version, kind, sequence, height) has no destination field. The destination is not in the message. The mirror brings its own.
Why the destination matters
When someone calls submit(message, signatures), the mirror rebuilds the digest on its own chain, using its own localDomain. It then runs ecrecover on each signature. That function takes a hash and a signature and returns the address that made the signature. Change the hash and the same signature returns a different address. The mirror compares every recovered address with its stored list of validators.
So a Sepolia signature shown to the Fuji mirror is checked against a Fuji digest. It recovers an address no validator holds, and the call reverts with UnknownSigner.
Measured on 2026-10-08, on one Sepolia message (a heartbeat, sequence 1274) with its four signatures: rebuilt with the Sepolia domain, all four recovered addresses matched the listed signers. Rebuilt with the Fuji domain, and again with the Arc domain, none of the eight recovered addresses matched a listed signer.
The binding does real work. On the same day the Sepolia and Fuji mirrors listed the same four validator addresses, and /v1/routes shows one mirror address on Sepolia, Fuji and Arc. Neither signers nor contract address tell the chains apart. The domain in the digest does.
The mirror checks the sequence before any signature. Sepolia expected 1278 and Fuji expected 1016 at 16:34 UTC on 2026-10-08, so a copied message would usually stop at OutOfSequence on Fuji first. The domain binding is what holds when the numbers happen to line up.
Order and duplicates
Strict sequence. Each mirror keeps nextDomainSeq. It applies a message only when the message's sequence equals that number exactly. Anything else reverts with OutOfSequence(expected, got). After a message applies, the counter moves up by one. A message cannot apply twice, and message 42 cannot apply before 41. If 41 carries a revocation, nothing skips past it. A revert undoes every write, so a refused message does not use up a slot. Each destination counts on its own.
Ascending signer order. The signatures arrive as one byte string. The mirror requires each recovered address to be strictly greater than the one before it, or it reverts with SignersOutOfOrder. A repeat is never greater than itself, so one validator cannot be counted twice. The code comment gives the reason: one key can make many different valid signature strings over the same digest, and the order rule stops them passing as many signers, with no list or set to build. The mirror also refuses the upper-half form of a signature (HighS), the same signature written a second way. More checks are on the security page.
See it in one command
The message list has no filter by chain. Ask for a few and pick a Sepolia row.
curl -sL 'https://bridge.solidus.network/v1/messages?limit=20' \
| jq -c '.messages[] | select(.chainKey=="sepolia") | {domain, domainSeq}'
Then ask for that message's signatures. The route is in the Bridge API reference.
curl -sL https://bridge.solidus.network/v1/attestations/11155111/1274
Trimmed from the response on 2026-10-08, with one signer and one signature shortened:
{
"domain": 11155111,
"domainSeq": 1274,
"threshold": 3,
"signatures": [
{ "signer": "0x99a8...c272", "signature": "0x99bf...1c" }
]
}
The real response holds four signatures, in ascending order of signer address. Now rebuild the digest with Foundry's cast, once for Sepolia and once for Fuji, and ask whether the first signer signed it. The number 50002 is the Solidus testnet chain id, which all three mirrors returned from solidusChainId().
A=$(curl -sL https://bridge.solidus.network/v1/attestations/11155111/1274)
ID=$(echo "$A" | jq -r '.messageId[2:]')
HT=$(echo "$A" | jq -r '.solidusHeight')
SIGNER=$(echo "$A" | jq -r '.signatures[0].signer')
SIG=$(echo "$A" | jq -r '.signatures[0].signature')
TAG=$(printf SOLIDUS_BRIDGE_ATTESTATION_V1 | xxd -p -c 100)
for DEST in 11155111 43113; do
PRE=0x$TAG$(printf '%016x%08x%016x' 50002 $DEST 1274)$ID$(printf '%016x' $HT)
echo "destination $DEST"
cast wallet verify --address $SIGNER $(cast keccak $PRE) $SIG
done
Output, with addresses shortened:
destination 11155111
Validation succeeded. Address 0x99a8...c272 signed this message.
destination 43113
Error: Validation failed. Address 0x99a8...c272 did not sign this message.
The same bytes, the same signature and the same signer. Only the destination changed.
Keep reading
- What a credential status bridge is, and why a contract on another chain needs one
- How to gate a smart-contract function on a credential without seeing the data behind it
- How validators sign a status message and a gateway delivers it to another chain
- Read the mirror contract from Solidity, TypeScript and the command line