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

Why a gate closes when the bridge goes quiet, and how to choose maxAge

A gate built on Solidus Bridge refuses with reason 5, STALE, when the bridge goes quiet. maxAge sets how quiet is too quiet, and you choose it. This page covers the EVM gate on Sepolia, Fuji and Arc. Check a condition without seeing the data builds the gate. How validators sign and the gateway delivers shows what a delivery stall looks like.

What stale means

The gate runs its checks in a fixed order and reports the first failure: no export (1), not active (2), issuer not accredited (3), expired (4), stale (5). STALE is last. A holder with no export can go and get one. A holder behind a silent bridge can only wait.

The last check reads the mirror's one stored heartbeat. It returns STALE when none has been applied, or when this holds:

hb.solidusTimestamp == 0 || hb.solidusTimestamp + maxAge < block.timestamp

maxAge is the caller's choice, in seconds. It is the third argument of the onlyCredentialed modifier and the maxAge field of checkGate in @solidus-network/bridge. The gate library has no default. The example vault on Sepolia passes one day (its MAX_AGE() read 86400 on 2026-10-08), as an example, not advice. A holder who gets STALE passed the other four checks against what the mirror last heard.

Why a gate closes on silence

A destination chain learns of a revocation only from a message. If none is applied, the chain cannot tell "nothing changed" from "something changed and nobody told me". Trusting the last status forever would accept a revoked credential while the transport stays down.

Heartbeats give the gate proof of life. At the end of a block, the Solidus chain queues a heartbeat for each enabled destination whose interval has passed, stamped with the block time in seconds. The mirror accepts messages strictly in sequence, so applying a heartbeat means every message queued before it is already applied. A fresh heartbeat says the mirror is current up to its timestamp. An old one says nothing about the time since.

So maxAge is the blind window you accept. A revocation queued in the last maxAge seconds may not be applied yet. On a healthy bridge the real window is the delivery lag. In a stall it grows to maxAge. What happens when a credential is revoked follows one revocation along that path.

Choose maxAge

Use a method, not a number from this page.

  1. Measure the spacing between heartbeats for your destination.
  2. Measure the lag from queue time to the destination block that applies the beat. It holds signing, the gateway's polling and inclusion.
  3. Add them. The sum is the oldest a heartbeat gets on a healthy bridge, just before the next one is applied.
  4. Add room for missed beats. Each one adds a spacing.
  5. Add the finality wait only where it counts. A contract reads the block its transaction lands in, so the wait does not count there. It counts for off-chain reads at the safe or finalized tag, and in the API's heartbeatAgeSecs, which starts when the API sees the beat applied. At about 17:08 UTC on 2026-10-08, one Sepolia heartbeat read 312 seconds old at latest, 348 at safe and 564 at finalized.

Governance sets the spacing for each destination, so measure it again later. One day, 2026-10-08:

Quantity Sepolia Fuji Arc
Gap between queued heartbeats, seconds 600 to 601 (12 gaps) 600 to 601 (12 gaps) 600 to 601 (11 gaps)
Queue to destination block, median and max, seconds 73 and 181 (11 beats) 22 and 44 (13 beats) 15 and 49 (12 beats)
Oldest age before the next beat, max, seconds 781 (10 pairs) 645 (12 pairs) 630 (11 pairs)
Destination block to the API's "applied", seconds 797 to 1167 (11 beats) 22 to 84 (13 beats) 4 to 5 (12 beats)

Reading lastHeartbeat() on each mirror 34 times between 17:06 and 17:25 UTC found three heartbeats per chain, 600 and 601 seconds apart.

This is arithmetic on one chain and one day, not a value to copy. Sepolia's oldest healthy age of 781 seconds plus three missed beats of 600 is 2,581 seconds.

The clock. The two chains keep separate clocks, and a heartbeat can be ahead of the destination's. So the gate adds maxAge to the timestamp. Subtracting the timestamp from the block time would underflow and revert. The lag figures above include the offset between the clocks. Off chain, pass now to checkGate as the latest block's timestamp, because it defaults to your machine's clock. The API's heartbeatAgeSecs is a different measure again, counted from when the API saw the beat applied. At 17:09:32 UTC it said 619 for Sepolia while the mirror's own age was 372.

Two values compared

Two illustrations, against the Sepolia numbers.

Tight, 900 s Loose, 86,400 s
Healthy bridge Open, with 119 s to spare over the oldest age measured Open
One heartbeat missed Closed to honest holders until the next beat is applied Open
Delivery stops Closes about 900 s after the last heartbeat's timestamp Stays open up to a day after it
Revocations it can miss Those queued in the last 15 minutes Those queued in the last day

A value at or below spacing plus lag refuses honest holders on a healthy bridge. With maxAge 600, the Sepolia gate would have been closed for 14 to 181 seconds of each cycle in the 10 cycles measured. A loose value avoids that and costs a longer blind window. Match it to what a late revocation costs your contract.

Show the right message

STALE says nothing about the holder. Their credential passed the other four checks as the mirror last knew them, and nothing they do changes the result. Say so:

Checks on this network are paused. Your credential is not the problem. Try again later.

Do not tell them to re-verify, re-export or contact the issuer. Do not word it like a revocation, which is NOT_ACTIVE. Call checkGate before you send the transaction, so the holder reads this before paying for a revert. gateReasonName(result.reason) returns stale, and heartbeatAgeSecs gives the age. Status shows heartbeat age live.

See it in one command

Read the Sepolia mirror's latest heartbeat and the latest block time through the proxy, then apply the gate's formula for three maxAge values. Read the mirror contract explains --nonce 0.

M=0x0a5a0d19206f436d67B7b87BF4c889d5b769517A
R=https://rpc.solidus.network/bridge/11155111
HB=$(cast call $M 'lastHeartbeat()(uint64,uint64,bytes32)' --rpc-url $R --nonce 0 | head -1 | cut -d' ' -f1)
NOW=$(cast block latest -f timestamp --rpc-url $R)
echo "heartbeat $HB  block $NOW  age $((NOW - HB))"
for MAXAGE in 60 900 86400; do
  if (( HB == 0 || HB + MAXAGE < NOW )); then echo "maxAge $MAXAGE -> STALE"; else echo "maxAge $MAXAGE -> fresh"; fi
done
heartbeat 1791480193  block 1791480300  age 107
maxAge 60 -> STALE
maxAge 900 -> fresh
maxAge 86400 -> fresh

Run at 17:25 UTC on 2026-10-08. Solidus stamped the heartbeat 17:23:13 and the Sepolia block is 107 seconds later, so a maxAge of 60 refuses.

The control reads an address with no contract. It prints an error and no age.

cast call 0x0000000000000000000000000000000000000001 'lastHeartbeat()(uint64,uint64,bytes32)' \
  --rpc-url $R --nonce 0
# Error: could not decode output; did you specify the wrong function return data type?

The Bridge API reference defines heartbeatAgeSecs. Integration steps are on developers, and short answers are in the faq.

Keep reading