Solidus Bridge runs on testnet. Everything shown here is test data with no monetary value.

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. credentialTypeHash and issuerDidHash pick the credential. maxAge is 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. deposit is gated and withdraw is not, on purpose. If withdraw were 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