How to gate a smart-contract function on a credential without seeing the data behind it
This guide gates a Solidity function on a Solidus credential with one modifier, and shows the same check from TypeScript. Your contract never receives the credential.
What you are building
You are adding one modifier to a Solidity function. Only an address that holds a valid Solidus credential can then call it. Your contract never receives the credential. It reads a small record that Solidus Bridge keeps on your chain: a hash of the issuer, a hash of the credential type, the holder key, a status, an expiry and the Solidus block height it came from. When the check fails, the call reverts with a number that says why. The live routes list a mirror, a registry and this example for the Sepolia, Fuji and Arc testnets. To see how that record gets there, read what a credential status bridge is.
Add the gate
Inherit SolidusGated and put onlyCredentialed on the function. This is the base contract, from SolidusGated.sol, with the header comment removed:
abstract contract SolidusGated {
ISolidusCredentialMirror public immutable mirror;
constructor(ISolidusCredentialMirror mirror_) {
mirror = mirror_;
}
/// Reverts `SolidusGate.CredentialRequired` with the reason, so the caller learns WHY.
modifier onlyCredentialed(bytes32 credentialTypeHash, bytes32 issuerDidHash, uint64 maxAge) {
SolidusGate.requireCredential(
mirror, SolidusGate.holderOf(msg.sender), credentialTypeHash, issuerDidHash, maxAge
);
_;
}
}
This is the smallest real use, ExampleGatedVault.sol. Only a holder of a KycL2 credential may deposit:
pragma solidity 0.8.28;
import {ISolidusCredentialMirror} from "../interfaces/ISolidusCredentialMirror.sol";
import {SolidusGated} from "../SolidusGated.sol";
contract ExampleGatedVault is SolidusGated {
bytes32 public constant KYC_L2 = keccak256("KycL2");
uint64 public constant MAX_AGE = 1 days;
bytes32 public immutable issuerDidHash;
mapping(address => uint256) public balanceOf;
error InsufficientBalance(uint256 have, uint256 want);
error TransferFailed();
constructor(ISolidusCredentialMirror mirror_, bytes32 issuerDidHash_) SolidusGated(mirror_) {
issuerDidHash = issuerDidHash_;
}
function deposit() external payable onlyCredentialed(KYC_L2, issuerDidHash, MAX_AGE) {
balanceOf[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
uint256 balance = balanceOf[msg.sender];
if (amount > balance) revert InsufficientBalance(balance, amount);
balanceOf[msg.sender] = balance - amount;
(bool ok,) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
}
}
The three contracts are SolidusGated.sol, lib/SolidusGate.sol and examples/ExampleGatedVault.sol, all under src/ in the bridge-contracts repository. The imports above follow that layout.
Three details matter:
- The arguments.
credentialTypeHashandissuerDidHashpick the credential.maxAgeis in seconds. It limits how old the mirror's last heartbeat from Solidus may be. It does not limit the age of the credential. - The caller. The modifier checks
msg.sender. The credential must be exported to the address that makes the call. - The exit.
depositis gated andwithdrawis not, on purpose. Ifwithdrawwere gated, a revocation, or a silent bridge, could trap a user's funds. Gate who may enter.
Read the reason
A failed check reverts with CredentialRequired(holder, credentialTypeHash, issuerDidHash, reason). The last field is a uint8 from GateReason. The checks run in this order, and the first failure is the one reported.
| reason | name | meaning |
|---|---|---|
| 0 | OK |
The check passed. You never see it in a revert. |
| 1 | NO_EXPORT |
The mirror has no record for this holder, credential type and issuer. |
| 2 | NOT_ACTIVE |
The record exists, but its status is not active. The codec defines revoked, expired and unbound. |
| 3 | ISSUER_NOT_ACCREDITED |
The mirror does not list this issuer as accredited. |
| 4 | EXPIRED |
The record has an expiry and it has passed. An expiry of 0 means none. |
| 5 | STALE |
No heartbeat has arrived, or the last one is older than maxAge. |
The code comment says these numbers are part of the interface and are never renumbered. STALE is the only reason that says nothing about the holder. The bridge has gone quiet, and the holder can only wait. The status page shows the heartbeat age for each chain.
You can read a reason without sending a transaction. This simulates a deposit from an address with no credential on Sepolia (one call, 2026-10-08):
cast call 0x923f75C32F3e1556Ee516d44E89088454D9E27a8 "deposit()" \
--from 0x0000000000000000000000000000000000000001 \
--rpc-url https://ethereum-sepolia-rpc.publicnode.com
# Error: ... execution reverted, data: "0xa641a24e...0001"
The first four bytes, 0xa641a24e, are the selector of CredentialRequired. The last word is 0001: reason 1, NO_EXPORT. Off chain, the @solidus-network/bridge package turns a number into a name with gateReasonName. The developers page shows it.
Where the addresses come from
Pass the mirror address to your constructor. SolidusGated stores it as immutable on purpose: a settable mirror would be an upgrade path in disguise. If you want to follow a replacement, read SolidusRegistry.mirrorOf(domain) where you make the call. The domain is the destination chain's own domain, which equals its chain ID on Sepolia, Fuji and Arc. Only the registry's owner can change the answer, and the owner is a timelock. On Sepolia its minimum delay reads 172800 seconds, which is 48 hours.
Read the addresses from the live /v1/routes response, which lists them for every chain. On 2026-10-08 Sepolia, Fuji and Arc used the same mirror address and the same registry address. On the Sepolia registry, mirrorOf(11155111) returns that mirror, and mirrorOf(1) returns the zero address. Each mirror binds its own domain into the digest it verifies, so a signature made for one chain cannot be replayed on another. The destination is inside what is signed explains how. Solana devnet lists a mirror address and no registry. This article covers the EVM gate.
Read the same gate from TypeScript
The @solidus-network/bridge package reads the gate the way the contract does, in the same order. A frontend can then explain a refusal before the holder pays for a reverted transaction. You pass your own viem client, and the package ships no RPC URL of its own.
import { checkGate, credentialTypeHash, gateReasonName, holderFromEvmAddress } from '@solidus-network/bridge'
const result = await checkGate({
client: publicClient,
mirror: mirrorAddress,
holder: holderFromEvmAddress(account.address),
credentialTypeHash: credentialTypeHash('KycL2'),
issuerDidHash,
maxAge: 86_400n,
})
if (!result.ok) console.log(`cannot proceed: ${gateReasonName(result.reason)}`)
The result holds ok, reason, exportId and heartbeatAgeSecs. Version 0.2.0 was the latest on npm on 2026-10-08. A result near the maxAge boundary is advisory, because the contract measures against the destination chain's block time and the package uses your machine's clock unless you pass now.
See it in one command
curl -sL https://bridge.solidus.network/v1/routes \
| jq '.chains[0] | {key, domain, contracts: {mirror: .contracts.mirror, registry: .contracts.registry}}'
{
"key": "sepolia",
"domain": 11155111,
"contracts": {
"mirror": "0x0a5a0d19206f436d67B7b87BF4c889d5b769517A",
"registry": "0x19aF92b387200827E0540f516FEd1468B09806A5"
}
}
Run on 2026-10-08. The full response lists every chain, and the jq filter keeps two fields of one.
Keep reading
- What a credential status bridge is, and why a contract on another chain needs one
- 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
- Read the mirror contract from Solidity, TypeScript and the command line