Read the mirror contract from Solidity, TypeScript and the command line
The mirror is the contract that holds Solidus credential status on a destination chain. What a credential status bridge is explains how a status gets there. Check a condition without seeing the data shows a gate that reads it. This page is the reference underneath: each public read, its signature, a real call on Ethereum Sepolia and the mistake it invites. Every read is free off chain. The destination is inside what is signed covers the write side.
The views at a glance
| view | returns | use it to |
|---|---|---|
latestExport(gateKey) |
an export id, or zeros | find the current export for a holder, type and issuer |
exportOf(exportId) |
an ExportRecord |
read one export's status and expiry |
issuerAccredited(issuerDidHash) |
a bool | check the issuer flag |
lastHeartbeat() |
a HeartbeatRecord |
tell whether the bridge is still alive |
nextDomainSeq() |
a uint64 |
see the sequence number the mirror accepts next |
threshold() |
a uint8 |
see how many validator signatures a message needs |
validators() |
an address[] |
list the signers |
localDomain() |
a uint32 |
confirm which chain you are reading |
solidusChainId() |
a uint64 |
read the Solidus chain id that signatures bind to |
mirrorOf(domain), on the registry |
an address | find the mirror for a domain |
The signatures come from the mirror's Solidity interfaces:
struct ExportRecord {
bytes32 issuerDidHash;
bytes32 credentialTypeHash;
bytes32 holder;
uint8 status;
uint64 validUntil;
uint64 solidusHeight;
}
struct HeartbeatRecord {
uint64 solidusTimestamp;
uint64 solidusHeight;
bytes32 globalRoot;
}
function solidusChainId() external view returns (uint64);
function localDomain() external view returns (uint32);
function nextDomainSeq() external view returns (uint64);
function exportOf(bytes32 exportId) external view returns (ExportRecord memory);
function latestExport(bytes32 gateKey) external view returns (bytes32 exportId);
function issuerAccredited(bytes32 issuerDidHash) external view returns (bool);
function lastHeartbeat() external view returns (HeartbeatRecord memory);
function validators() external view returns (address[] memory);
function threshold() external view returns (uint8);
function mirrorOf(uint32 solidusDomain) external view returns (address);
Set up once
ROUTES=$(curl -sL https://bridge.solidus.network/v1/routes)
MIRROR=$(echo "$ROUTES" | jq -r '.chains[] | select(.key=="sepolia") | .contracts.mirror')
REGISTRY=$(echo "$ROUTES" | jq -r '.chains[] | select(.key=="sepolia") | .contracts.registry')
RPC=https://rpc.solidus.network/bridge/11155111
c() { cast call "$@" --rpc-url $RPC --nonce 0; }
The RPC URL is a JSON-RPC proxy for one destination domain. Foundry's cast 1.7.1 sent a request the proxy refuses (eth_getTransactionCount is not proxied), so the helper passes --nonce 0. cast also prints a rounded number in brackets after large values. Every output below was run on 2026-10-08, between 16:50 and 16:57 UTC.
The lookup key
latestExport takes a gate key, not a holder. The key is the keccak256 hash of three 32-byte values packed one after another, with no separators, in this order: holder key, credential type hash, issuer hash. The credential type hash is keccak256 of the type's name as UTF-8 text, such as KycL2. The issuer hash is keccak256 of the issuer's DID as UTF-8 text.
The holder key is 32 bytes. An EVM address is 20, so it is left-padded: twelve zero bytes, then the address. Pad on the right and you get a different key. The mirror then answers with zeros, exactly as it does for a holder with no export, so the mistake looks like a missing credential. In Solidity:
bytes32 holder = bytes32(uint256(uint160(account)));
bytes32 key = keccak256(abi.encodePacked(holder, credentialTypeHash, issuerDidHash));
The views, one by one
latestExport
Returns the id of the current export for a key, or 32 zero bytes if none. The pointer moves only to an active export, so revoking an older export never closes the gate on a holder whose newer credential is fine. A non-zero id does not mean valid. Read the record next. The worked example below calls it.
exportOf
c $MIRROR 'exportOf(bytes32)((bytes32,bytes32,bytes32,uint8,uint64,uint64))' 0x0000000000000000000000000000000000000000000000000000000000000001
# (0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000, 0, 0, 0)
status is 1 active, 2 revoked, 3 expired or 4 unbound. validUntil is Unix seconds, and 0 means no expiry. An unknown id does not revert. It returns zeros, and a stored record never has status 0, so test for that. The stored status does not change when validUntil passes. Compare it to block.timestamp yourself.
issuerAccredited
c $MIRROR 'issuerAccredited(bytes32)(bool)' $(cast keccak 'did:example:issuer')
# false
The flag is per issuer, not per credential type. Any credential status message or issuer status message can set it, and the latest one wins. false covers both "never heard of this issuer" and "not accredited".
lastHeartbeat
c $MIRROR 'lastHeartbeat()((uint64,uint64,bytes32))'
# (1791478392 [1.791e9], 5661264 [5.661e6], 0xebc2a7f366f1b04ac4553f7af879047cfd6646866891d4a3ec0517c424f93423)
solidusTimestamp is seconds on the Solidus clock, and 1791478392 is 16:53:12 UTC. A zero timestamp is the unset default, and the gate reports it as stale. The two chains keep separate clocks, so the timestamp can be ahead of block.timestamp. Never write block.timestamp - solidusTimestamp in Solidity. It underflows and reverts. The gate adds instead: solidusTimestamp + maxAge < block.timestamp.
nextDomainSeq
c $MIRROR 'nextDomainSeq()(uint64)'
# 1280
This is the sequence number the mirror accepts next. It counts messages of every kind, heartbeats included, so it is not a count of credentials. Mind the block tag. At 16:55 UTC the proxy returned 1280 for latest, 1279 for safe and 1278 for finalized.
threshold and validators
c $MIRROR 'threshold()(uint8)'
# 3
c $MIRROR 'validators()(address[])'
# [0xf6d4f63070cdD3F1e9C1c405b179703c128bfeCa, 0x99a89F2762F2E858df197AD4F24389BFF86ec272, 0xa4E099D7ffc0a840961266B96bCbaAbD5cC30F46, 0xfF01595dE6B8AC7789010559bA52881056411612]
Three of four validators must sign a message. validators() returns the order given at deployment, not a sorted order. The first address above is greater than the second. Signatures must be sorted by signer address, ascending, so sort before you build them. Neither value can change. A replacement is a new mirror behind the registry.
localDomain and solidusChainId
c $MIRROR 'localDomain()(uint32)'
# 11155111 [1.115e7]
c $MIRROR 'solidusChainId()(uint64)'
# 50002 [5e4]
localDomain is this chain's domain, which equals its chain id on Sepolia, Fuji and Arc. Sepolia, Fuji and Arc share one mirror address, so the address tells you nothing. localDomain does. solidusChainId is the Solidus testnet chain id that every signature binds to.
mirrorOf
c $REGISTRY 'mirrorOf(uint32)(address)' 11155111
# 0x0a5a0d19206f436d67B7b87BF4c889d5b769517A
c $REGISTRY 'mirrorOf(uint32)(address)' 1
# 0x0000000000000000000000000000000000000000
The registry maps a domain to its mirror. An unknown domain gives the zero address, and the registry can repoint a domain, so read it at call time and do not hardcode the mirror.
Worked example: one gate key, one read
The holder is 0x…00a1, the type is KycL2, and the issuer is the example DID did:example:issuer.
HOLDER=$(cast pad --left --len 32 0x00000000000000000000000000000000000000a1)
TYPE=$(cast keccak KycL2)
ISSUER=$(cast keccak 'did:example:issuer')
KEY=$(cast keccak $(cast concat-hex $HOLDER $TYPE $ISSUER))
echo $KEY
# 0xa37189faccb65a299afc7bed05ce71d25d38af9228a2713230a7124962145440
c $MIRROR 'latestExport(bytes32)(bytes32)' $KEY
# 0x0000000000000000000000000000000000000000000000000000000000000000
The mirror holds no export under that key, so it answers with zeros. A gate reports that as reason 1, no export. Padding the holder on the right gives 0x4e2ff44747153e8d41b8d8ff5dc6fa97643e43025000a13310e8eb5cadb9a4a0 instead.
Reading it from TypeScript
The published @solidus-network/bridge package (0.2.0 on 2026-10-08) exports MIRROR_ABI, gateKey, holderFromEvmAddress, credentialTypeHash and issuerDidHash. MIRROR_ABI covers four views: latestExport, exportOf, issuerAccredited and lastHeartbeat. For the others, pass your own ABI fragment, as below. gateKey throws if the holder is not 32 bytes, which catches the unpadded address.
import { createPublicClient, http, parseAbi } from 'viem'
import { MIRROR_ABI, credentialTypeHash, gateKey, holderFromEvmAddress, issuerDidHash } from '@solidus-network/bridge'
const mirror = '0x0a5a0d19206f436d67B7b87BF4c889d5b769517A'
const client = createPublicClient({ transport: http('https://rpc.solidus.network/bridge/11155111') })
const key = gateKey(
holderFromEvmAddress('0x00000000000000000000000000000000000000a1'),
credentialTypeHash('KycL2'),
issuerDidHash('did:example:issuer'),
)
const exportId = await client.readContract({ address: mirror, abi: MIRROR_ABI, functionName: 'latestExport', args: [key] })
const nextSeq = await client.readContract({
address: mirror,
abi: parseAbi(['function nextDomainSeq() view returns (uint64)']),
functionName: 'nextDomainSeq',
})
Run on 2026-10-08, this gave the same key as cast, 0xa37189fa…5440, a zero exportId and nextSeq of 1280n.
See it in one command
Ask all three EVM mirrors which domain they serve. The address is the same on each.
for d in 11155111 43113 5042002; do
echo "$d $(cast call 0x0a5a0d19206f436d67B7b87BF4c889d5b769517A 'localDomain()(uint32)' --rpc-url https://rpc.solidus.network/bridge/$d --nonce 0 | cut -d' ' -f1)"
done
# 11155111 11155111
# 43113 43113
# 5042002 5042002
The routes endpoint that lists the addresses is in the Bridge API reference. The integration steps are on developers, live chain health is on status, and short answers are in the faq.
Keep reading
- How to gate a smart-contract function on a credential without seeing the data behind it
- The destination is inside what is signed, so a signature cannot be replayed on another chain
- How validators sign a status message and a gateway delivers it to another chain
- How to export a credential issued by Verify to another chain, and what Solidus checks on the way