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
solidity
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.
shieldAssetispayableand reverts withInsufficientGasCoveragebelowflatGasSurcharge(). 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
solidity
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
solidity
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
solidity
// 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
shieldAssetas 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.