What the credential mirror contract can and cannot do
This page checks on chain what the credential mirror can change, who can call it and which operations it has no function for.
What it does
The mirror is the contract on a destination chain that holds Solidus credential status. It stores one record per export, one flag per issuer and the latest heartbeat. It changes any of them only when a message carries valid signatures from enough known validators, three of four on Sepolia, Fuji and Arc.
For what it exposes, see read the mirror contract. For how a message reaches it, see what a credential status bridge is. Each signature binds to one destination, as the destination is inside what is signed shows. This page covers the other side: what the contract cannot do. Each limit comes with the Solidity that causes it and a read you can repeat.
The write surface
The mirror has twelve external functions. Eleven are view. One, submit, writes state. None is payable. The registry that points integrators at a mirror has three functions that write, and all three are owner-only.
| contract | function | selector | who may call | what it writes |
|---|---|---|---|---|
| mirror | submit(bytes,bytes) |
0x39b973ad |
anyone | the next sequence number, then what the message carries: an export with its pointer and issuer flag, an issuer flag alone, or a heartbeat |
| registry | setMirror(uint32,address) |
0x753ab46d |
the owner | which mirror serves a domain |
| registry | transferOwnership(address) |
0xf2fde38b |
the owner | the owner |
| registry | renounceOwnership() |
0x715018a6 |
the owner | the owner, set to zero |
submit has no allowlist. The signatures decide whether a call applies, and a call that fails any check reverts as a whole, so a refused message writes nothing. The eleven views cost nothing to call off chain. The registry's setMirror is the one function that changes where integrators look, and a timelock delay stands in front of it.
Seven claims, each with its proof
The Solidity and the deployed contract are one program, and the Solidity is public in the bridge-contracts repository. Compiled with solc 0.8.28 (optimizer on, 200 runs, Cancun), the mirror's source gives code the same size as the code on Sepolia, 5,040 bytes. The two are identical except for nine 32-byte slots that hold the constructor values and the 32-byte compiler metadata hash. The registry, built against version 5.6.1 of its access-control library, is 742 bytes and differs only in its metadata hash.
Every deployed read ran on 2026-10-08 between 17:07 and 17:18 UTC, on Ethereum Sepolia against the mirror 0x0a5a…517A, except the Fuji comparison in claim 2. The scans are in the last section. A control sits beside each read: the same read on a contract that should answer differently.
| # | claim | Solidity | deployed |
|---|---|---|---|
| 1 | No owner, no admin function | No Ownable, onlyOwner, msg.sender or tx.origin in the mirror or its two libraries. Control: the registry has Ownable and onlyOwner. |
Scan 2: owner(), transferOwnership(address), setThreshold(uint8) and pause() are not among the code's selectors, and submit is. Scan 7: cast call $M 'owner()(address)' reverts. The same call on the registry returns the timelock 0x23A8…d294. |
| 2 | The chain id, local domain, threshold and validator list are fixed at construction | solidusChainId, solidusDomain, localDomain and threshold are immutable. _validators.push(v) and _isValidator[v] = true sit in the constructor and nowhere else. |
Scan 4: the code holds 3 as a constant at 2 places, 11155111 at 3 and 50002 at 4. Scan 8: on Fuji the same address holds the same 5,040 bytes, and 9 bytes differ, all inside the three local-domain constants. threshold() reads 3 and validators() lists four addresses. |
| 3 | It is not a proxy, has no upgrade path and cannot delete itself | No delegatecall, selfdestruct, create2 or new in the mirror or its libraries, and no initializer. |
Scan 5: cast storage on the EIP-1967 implementation slot and on the admin slot reads zero in both. Control: 0xE737…E275, a third-party proxy on Sepolia, reads non-zero in both. Scan 3 finds no DELEGATECALL, CREATE, CREATE2 or SELFDESTRUCT, and scan 2 finds no upgradeTo(address). |
| 4 | It moves no tokens and no ether | No token interface and no .call, .send or .transfer. The only assembly is a calldataload shift. |
Scan 3 finds no CALL or CALLCODE. Its one STATICCALL cannot change state, and ecrecover is the only external read in the source. Control: the example vault, which pays out ether, has 1 CALL. |
| 5 | No call can give it ether | No payable function, no receive and no fallback. |
The code opens by reverting when CALLVALUE is not zero. Scan 6: a simulated call that carries 1 wei reverts, and so does a call with empty calldata. Control: the same value call on Sepolia WETH returns 0x. cast balance $M reads 0. |
| 6 | submit is the only function that writes state |
One nonpayable function and eleven view. The three _apply… writers are private, and submit is their only caller. |
Scan 1: the code answers 12 selectors, the same 12 as the source's ABI. A call with the unknown selector 0x12345678 reverts. The byte comparison above ties the code to the source. |
| 7 | No address is privileged | submit never reads the caller. It requires threshold distinct validators to have signed, in ascending address order. |
Scan 3 finds no CALLER or ORIGIN. Control: the vault has 4 CALLER. A stranger's simulated submit(0x,0x) reverts with BadLength(18, 0), selector 0x2e728bb2. That is a message-format error, not an authorization error. |
Claim 5 covers calls. The EVM lets a self-destructing contract send ether to any address, and no code at the target can refuse it.
Claim 2 needs one note. Four values use Solidity's immutable keyword: the Solidus chain id, the Solidus domain, the local domain and the threshold. The compiler places them in the code and not in storage. The validator list is a storage array and a mapping, so its proof is different. The source writes it only in the constructor, and submit is the only function that writes at all. Claim 6 carries that proof.
How a mirror is replaced
A mirror is never changed in place. A replacement is a new mirror with its own fixed values. The registry's owner then calls setMirror(domain, newMirror). An integrator who reads mirrorOf(domain) at call time follows the change. One who stored the address keeps the old mirror.
The owner is a timelock, 0x23A8…d294. On Sepolia its minimum delay reads 172800 seconds, which is 48 hours, the figure in check a condition without seeing the data. A stranger's simulated setMirror reverts with OwnableUnauthorizedAccount (0x118cdaa7) and the stranger's address. The same simulation from the timelock returns 0x. transferOwnership and renounceOwnership give the stranger the same revert. The owner can also set a domain to the zero address, and the timelock's simulation accepts that too.
The delay applies to repointing a domain. It does not apply to submit, which has no owner to wait for: anyone delivers a signed message at any time.
The owner has no way to reach the mirror. The registry's code contains no CALL, STATICCALL or DELEGATECALL, so it cannot call any contract. The mirror has no owner-only function to call.
See it in one command
These reads use a public Sepolia RPC, because the Solidus proxy does not serve eth_getCode, eth_getStorageAt or eth_getBalance. --override-balance only lets the simulated sender hold 1 ether. Nothing is sent. Run on 2026-10-08.
Each scan prints a number or a value that you can compare with the line under it. A zero counts as evidence only because the control beside it returns a non-zero result on the same scan.
RPC=https://ethereum-sepolia-rpc.publicnode.com
M=0x0a5a0d19206f436d67B7b87BF4c889d5b769517A # mirror
R=0x19aF92b387200827E0540f516FEd1468B09806A5 # registry
V=0x923f75C32F3e1556Ee516d44E89088454D9E27a8 # example vault, a control
P=0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275 # third-party proxy, a control
F=0x0000000000000000000000000000000000000001
trim() { local c; c=$(cast code "${1}" --rpc-url $RPC); echo "${c:0:$((${#c}-106))}"; } # cut the 53-byte compiler metadata trailer
OPS='^(CALL|CALLCODE|DELEGATECALL|CREATE|CREATE2|SELFDESTRUCT|CALLER|ORIGIN)$'
# scan 1: the selectors the code answers
SELS=$(cast disassemble $(trim $M) | awk '$(2)=="PUSH4"{p=$(3);next} $(2)=="EQ"&&p{print p} {p=""}')
echo "$SELS" | wc -l
# 12
# scan 2: candidate functions, with submit as the control
for s in 'submit(bytes,bytes)' 'owner()' 'transferOwnership(address)' 'upgradeTo(address)' \
'setThreshold(uint8)' 'pause()' 'withdraw(uint256)'; do
echo "$s $(echo "$SELS" | grep -c $(cast sig "$s"))"; done
# submit(bytes,bytes) 1
# owner() 0
# transferOwnership(address) 0
# upgradeTo(address) 0
# setThreshold(uint8) 0
# pause() 0
# withdraw(uint256) 0
# scan 3: opcodes that call out, create, delete, or read the caller
cast disassemble $(trim $M) | awk '{print $(2)}' | grep -cE "$OPS"
# 0
cast disassemble $(trim $V) | awk '{print $(2)}' | grep -E "$OPS" | sort | uniq -c
# 1 CALL
# 4 CALLER
cast disassemble $(trim $R) | awk '{print $(2)}' | grep -cE '^(CALL|CALLCODE|DELEGATECALL|STATICCALL)$'
# 0 (the registry cannot call any contract)
# scan 4: the constructor values sit in the code as constants
cast disassemble $(trim $M) | grep -E 'PUSH32 0x0+(3|c352|aa36a7)$' | awk '{print $NF}' | sed -E 's/^0x0+/0x/' | sort | uniq -c
# 2 0x3
# 3 0xaa36a7
# 4 0xc352
# scan 5: no proxy slots (the control is a proxy)
for slot in 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; do
echo "mirror $(cast storage $M $slot --rpc-url $RPC)"
echo "control $(cast storage $P $slot --rpc-url $RPC)"; done
# mirror 0x0000000000000000000000000000000000000000000000000000000000000000
# control 0x0000000000000000000000000bf2a7bc86e37684d45281e6a101dd639e918223
# mirror 0x0000000000000000000000000000000000000000000000000000000000000000
# control 0x0000000000000000000000005abd1d39301fd4484b829cd1f0d4f17a6af9569f
# scan 6: no ether in, and a value call reverts (the control is Sepolia WETH)
cast balance $M --rpc-url $RPC
# 0
cast call $M --value 1 --from $F --override-balance $F:1000000000000000000 --rpc-url $RPC
# Error: server returned an error response: error code 3: execution reverted
cast call 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9 --value 1 --from $F --override-balance $F:1000000000000000000 --rpc-url $RPC
# 0x
# scan 7: the mirror has no owner(), the registry does
cast call $M 'owner()(address)' --rpc-url $RPC
# Error: server returned an error response: error code 3: execution reverted
cast call $R 'owner()(address)' --rpc-url $RPC
# 0x23A8E34E422A9c2E88c1F205070758b84191d294
# scan 8: the same address on Fuji holds the same code, except the local domain
diff <(cast code $M --rpc-url $RPC | fold -w2) \
<(cast code $M --rpc-url https://api.avax-test.network/ext/bc/C/rpc | fold -w2) | grep -c '^<'
# 9
The addresses come from the live /v1/routes response, which the Bridge API reference describes. Integration steps are on developers, chain health is on status, the trust model is on security, and short answers are in the faq.
Keep reading
- How to export a credential issued by Verify to another chain, and what Solidus checks on the way
- What happens between an issuer revoking a credential and a contract on another chain refusing it
- Why a gate closes when the bridge goes quiet, and how to choose maxAge
- What a delivery costs in gas, and who pays