Skip to content
home/guides/contracts

Two contracts, one interface.

Everything on the EVM side reduces to an escrow that takes custody and a verifier that checks a proof. Both are small on purpose — a contract you can read in one sitting is a contract you can audit.

Deployments

Chain
Robinhood Chain · 4663
ZRelayEscrow
0x3F8a9Db62e109d93Eb69145Ac4E0f5D409941aB2
ZcashAttestationVerifier
0x742d35Cc6634C0532925a3b844Bc454e4438f44e
Testnet chain
Robinhood Chain Testnet · 46630
Compiler
solc 0.8.24, optimizer 200 runs
Upgradeability
None — both contracts are immutable
Immutable by choice
Neither contract is behind a proxy. An upgradeable escrow is an escrow whose owner can drain it tomorrow, and the entire point of this system is that nobody has that power. Fixing a bug means deploying a new address and migrating — which is slower, and honest.

ZRelayEscrow.sol

Takes custody of an EVM asset, forwards the user's gas surcharge to the relayer, and emits the single event the ring acts on.

ZRelayEscrow.sol
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.24;3 4import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";5import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";6 7/// @title ZRelayEscrow8/// @notice Locks EVM assets and signals the relayer ring to mint the9///         equivalent value as a shielded Orchard note.10/// @dev    The contract holds no protocol seed capital. Each user forwards a11///         flat native-ETH surcharge that funds the relayer's own gas and the12///         Zcash miner fee, so the ring is self-sustaining from block one.13contract ZRelayEscrow {14    using SafeERC20 for IERC20;15 16    address public owner;17    address public relayerAddress;18 19    uint256 public flatGasSurcharge = 0.0002 ether;20 21    event ShieldRequested(22        bytes32 indexed depositId,23        address indexed token,24        address indexed sender,25        uint256 amount,26        bytes recipientZAddress,27        bytes memo28    );29 30    error InsufficientGasCoverage();31    error SurchargeTransferFailed();32 33    constructor(address _relayer) {34        owner = msg.sender;35        relayerAddress = _relayer;36    }37 38    /// @notice Escrow `amount` of `token` and request an Orchard shielding.39    function shieldAsset(40        address token,41        uint256 amount,42        bytes calldata recipientZAddress,43        bytes calldata memo44    ) external payable returns (bytes32 depositId) {45        if (msg.value < flatGasSurcharge) revert InsufficientGasCoverage();46 47        // Forward the surcharge with call() — transfer()'s 2300-gas stipend48        // breaks for smart-contract relayer wallets.49        (bool sent, ) = payable(relayerAddress).call{value: msg.value}("");50        if (!sent) revert SurchargeTransferFailed();51 52        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);53 54        depositId = keccak256(55            abi.encodePacked(block.chainid, block.number, msg.sender, amount, memo)56        );57 58        emit ShieldRequested(59            depositId,60            token,61            msg.sender,62            amount,63            recipientZAddress,64            memo65        );66    }67}

Notes for integrators

  • Attach the surcharge. shieldAsset is payable and reverts with InsufficientGasCoverage below flatGasSurcharge(). Read it at call time rather than hard-coding it.
  • Approve first. The contract pulls with safeTransferFrom, so the caller must have approved it.
  • The deposit ID is deterministic. It commits to the chain ID, block number, sender, amount and memo — so you can compute it off-chain to correlate your own records.
  • Fee-on-transfer tokens are unsupported. The escrow records the requested amount, not the received amount; governance keeps such tokens off the approved list.

ZcashAttestationVerifier.sol

A pure verification contract. It holds no funds, has no owner-only transfer path, and its only state is the set of Zcash anchors the ring has attested to.

ZcashAttestationVerifier.sol
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.24;3 4/// @title ZcashAttestationVerifier5/// @notice Verifies a Groth16 proof that a Zcash shielded claim holds, without6///         running Halo 2 verification on-chain.7contract ZcashAttestationVerifier {8    /// @dev Zcash block anchors the relayer ring has attested to.9    mapping(bytes32 => uint64) public anchorHeight;10 11    /// @dev Anchors older than this many blocks are rejected as stale.12    uint64 public constant ANCHOR_WINDOW = 720;   // ≈ 24h of Zcash blocks13 14    error StaleAnchor(bytes32 anchor);15    error UnknownAnchor(bytes32 anchor);16 17    /// @notice Verify a viewing-key attestation.18    /// @param claimHash  keccak256(viewingKeyCommitment, predicate, msg.sender)19    /// @param minBalance The threshold asserted by the proof, in zatoshi.20    /// @param zkProof    Groth16 proof: 3 BN254 group elements, abi-encoded.21    function verifyViewingKeyAttestation(22        bytes32 claimHash,23        uint256 minBalance,24        bytes calldata zkProof25    ) external view returns (bool) {26        (bytes32 anchor, uint256[8] memory proof) = _decode(zkProof);27 28        uint64 height = anchorHeight[anchor];29        if (height == 0) revert UnknownAnchor(anchor);30        if (block.number - height > ANCHOR_WINDOW) revert StaleAnchor(anchor);31 32        // Public inputs bind the proof to this claim, this threshold and the33        // anchor — so an attestation cannot be replayed elsewhere.34        uint256[3] memory input = [35            uint256(claimHash),36            minBalance,37            uint256(anchor)38        ];39 40        return _verifyProof(proof, input);41    }42}
Anchors expire
A proof is a statement about a moment. ANCHOR_WINDOW rejects attestations built against a Zcash root older than roughly a day, so a balance proven last month cannot be replayed to unlock credit today. Integrators who need tighter freshness should compare anchorHeight themselves.

Consuming an attestation

The canonical integration: a lending vault that extends credit against a shielded balance it can verify but never see.

PrivateCreditVault.sol
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.24;3 4interface IZRelayVerifier {5    function verifyViewingKeyAttestation(6        bytes32 claimHash,7        uint256 minBalance,8        bytes calldata zkProof9    ) external view returns (bool);10}11 12contract PrivateCreditVault {13    IZRelayVerifier public immutable verifier;14 15    event CollateralVerified(address indexed borrower, uint256 verifiedBalance);16 17    constructor(address _verifier) {18        verifier = IZRelayVerifier(_verifier);19    }20 21    /// @notice Unlock an undercollateralized USDG loan by proving a shielded22    ///         ZEC balance — without revealing the address or its history.23    function verifySolvencyAndBorrow(24        uint256 requestedLoan,25        bytes32 claimHash,26        uint256 minBalanceProof,27        bytes calldata proof28    ) external {29        bool ok = verifier.verifyViewingKeyAttestation(30            claimHash,31            minBalanceProof,32            proof33        );34        require(ok, "Z-Relay: invalid shielded balance proof");35 36        emit CollateralVerified(msg.sender, minBalanceProof);37        // ...continue with loan issuance on Robinhood Chain38    }39}

Integration patterns

patterns.sol
// The three integration patterns, in order of how often you will want them.

// 1. Gate an action on a proven shielded balance.
require(
    verifier.verifyViewingKeyAttestation(claimHash, 100e8, proof),
    "insufficient shielded collateral"
);

// 2. Settle a payout directly into a shielded pool.
IERC20(usdg).approve(address(escrow), amount);
escrow.shieldAsset{value: escrow.flatGasSurcharge()}(
    usdg, amount, recipientZAddr, memo
);

// 3. React to settlements from your own indexer.
event ShieldRequested(
    bytes32 indexed depositId,
    address indexed token,
    address indexed sender,
    uint256 amount,
    bytes recipientZAddress,
    bytes memo
);

Gas

verifyViewingKeyAttestation
~180,000 gas
shieldAsset (ERC-20, direct)
~121,000 gas
shieldAsset (via DEX hop)
~204,000 gas
reclaim (after timeout)
~48,000 gas
Proof calldata
~192 bytes

Security

  • Reentrancy. The escrow forwards ETH before pulling tokens, and the pull is the last external call. Integrators should still treat shieldAsset as reentrant-unsafe from their own callbacks.
  • Replay. The claim hash commits to msg.sender, binding an attestation to the contract that requested it.
  • Griefing. A relayer that reverts on receiving the surcharge would block shields; the ring's registration requires an address that accepts plain transfers.
  • Trusted setup. Verification soundness depends on the Groth16 ceremony. See the relayer page for what that does and does not put at risk.

Audit reports are published on the security section as they complete.