Cross-Contract Calls

Cross-Contract Calls require you to "Generate Proof" and, optionally, make use of "Relayers": See UX for Private Transactions UX for Private Transactions

Cross Contract calls are the primary method for RAILGUN to access external DeFi protocols and smart contracts. Any number of ordered contract calls can be wired together and executed against a private balance. These cross-contract interactions occur in the generalized RAILGUN Relay Adapt contract, which facilitates a number of serial multi-calls in a single block.

How it Works

First, (1) an unshield is executed against the private balance, moving tokens temporarily into the Relay Adapt contract. Then, (2) a multi-call processes any number of contract calls against the balance. Finally, (3) the result is shielded back into the user’s private balance. This all occurs in a single block and one transaction.

For example, Railway Wallet has integrated 0x API to facilitate private swaps. This is done through two serial cross-contract calls: First, the exact swap token amount is approved for spending. Second, the 0x token swap is performed. The contract spend approval is a failsafe operation on each swap transaction. It ensures that the swap amount is guaranteed to be approved during the execution block.

In the case of a contract call failure, the entire call will revert.

RAILGUN Cookbook and Recipes

The RAILGUN Cookbook simplifies forming these cross-contract transactions. Please see our guide on RAILGUN Cookbook and check the verified recipes that are available in the Cookbook SDK (Github).

The "Recipes" in the Cookbook contain structured calls that integrate directly into the Wallet SDK. Contributors are working on verified Recipes for DEX Swaps, Asset Vaults, Liquidity Provisioning (LP), Stablecoin Minting, Liquid Staking, and more.

Call Structure

The structure of these transactions is very similar to RAILGUN transfers and unshields, with 3 new fields.

crossContractCallsSerialized: {to, data, value} fields make up our array of crossContractCalls, which interact with external smart contracts through a series of ordered calls.

relayAdaptUnshieldERC20Amounts: The exact amounts that should be unshielded to process in this transaction. Make sure only to unshield the exact token amount that is required, as there is an unshield fee associated with this value (like any unshield transaction).

relayAdaptShieldERC20Addresses: A list of token addresses that are the result of the cross contract calls, to shield into private balance after the calls are complete. If its balance is 0, nothing will be shielded. These are the only tokens that will be shielded back, so make sure the list is complete. Any tokens that are not listed in this array and shielded during the transaction will not be recoverable.

NOTE: If using an existing Cookbook Recipe or writing your own for custom cross-contract calls, the Recipe will output these fields for you.

Example: Format 0x API private swap for cross-contract calls with Cookbook

See Use your zkApp privately.

Example: Format 0x API private swap for cross-contract calls without Cookbook

// Tokens to swap through 0x API.
const sellToken = {
    address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI
    amount: BigInt('0x100000000'),
}
const buyTokenAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7'; // USDT

// Amount of sell token will be modified by initial unshield, fee of 0.25%.
const sellTokenAmountAfterUnshieldFee = 
    sellToken.amount * (10000n - unshieldFeeBasisPoints) / 10000n;

// Approve the exact amount to swap with 0x.
const erc20 = new Contract(sellToken.address, erc20ABI, provider);
const transactionApprove0x = await erc20.populateTransaction.approve(
    quote.allowanceTarget,
    sellTokenAmountAfterUnshieldFee,
);

// Fetch quote data from 0x API.
const quote = await get0xAPIQuote(
    sellToken.address, 
    buyTokenAddress, 
    sellTokenAmountAfterUnshieldFee,
);
const transaction0xSwap = {
    to: quote.to,
    value: quote.value,
    data: quote.data,
};

const crossContractCalls: ContractTransaction[] = [
    transactionApprove0x,
    transaction0xSwap,
];

const relayAdaptUnshieldERC20Amounts = [sellToken];

// In case there's slippage or dust leftover, 
// make sure to shield the sellToken as well as buyToken.
const relayAdaptShieldERC20Recipients: RailgunERC20Recipient = [{
    tokenAddress: sellToken.address,
    recipientAddress: railgunWallet.getAddress(),
},
{
    tokenAddress: buyTokenAddress,
    recipientAddress: railgunWallet.getAddress(),
}];

Approvals

By default, you should assume that the Relay Adapt contract is not approved to spend any tokens. Because anyone can execute transactions against this contract, a user could potentially reset all spend allowances to 0 in any transaction.

However, because we execute cross-contract transactions inside a multi-call, we're safe from outside interactions during the execution block.

It's recommended to always approve the exact amount that you require in your transaction, as the initial step of the cross-contract calls. (As in the example above).

NOTE: When using the Cookbook, all Recipes will include the necessary approval call, so there is no additional code needed.

Impact of Unshield Fee

Because of the unshield fee, the amount of tokens in the transaction will be 0.25% less than the amount unshielded. This is very important if transacting a specific value, like for a swap.

For example, if we want to swap 100 DAI for wETH, the transaction will look like this:

  1. Unshield 100 DAI (relayAdaptUnshieldERC20Amounts), with a 0.25 DAI fee.

  2. Cross contract call: approve 99.75 DAI for swap spending.

  3. Cross contract call: swap 99.75 DAI for wETH.

  4. Shield wETH and any leftover DAI dust into private balance (relayAdaptShieldERC20Addresses).

NOTE: The Cookbook automatically calculates the unshield fees when using an existing Recipe or you write your own cross-contract calls as a Recipe.

Gas Estimate

//
// See Example code above to generate required fields: 
// - crossContractCallsSerialized
// - relayAdaptUnshieldERC20Amounts
// - relayAdaptShieldERC20Addresses
//

// Database encryption key. Keep this very safe.
const encryptionKey = '...';

const sendWithPublicWallet = false; // False if sending with Relayer. True if self-signing with public wallet.
const evmGasType: EVMGasType = getEVMGasTypeForTransaction(
  NetworkName.Ethereum,
  sendWithPublicWallet
);
const originalGasEstimate = 0n; // Always 0, we don't have this yet.

let originalGasDetails: TransactionGasDetails;
switch (evmGasType) {
  case EVMGasType.Type0:
  case EVMGasType.Type1:
    originalGasDetails = {
      evmGasType,
      originalGasEstimate,
      gasPrice: BigInt('0x100000'), // Proper calculation of network gasPrice is not covered in this guide
    }
    break;
  case EVMGasType.Type2:
    // Proper calculation of gas Max Fee and gas Max Priority Fee is not covered in this guide. See: https://docs.alchemy.com/docs/how-to-build-a-gas-fee-estimator-using-eip-1559
    const maxFeePerGas: BigInt('0x100000');
    const maxPriorityFeePerGas: BigInt('0x010000');

    originalGasDetails = {
      evmGasType,
      originalGasEstimate,
      maxFeePerGas,
      maxPriorityFeePerGas,
    }
    break;
}

// From their private balance, the user must select a token to pay the relayer fee
const selectedTokenFeeAddress = '0xabc...123';
const selectedRelayer: SelectedRelayer = ...; // See "Relayers" section to select a relayer

// Token Fee for selected Relayer.
const feeTokenDetails: FeeTokenDetails = {
  tokenAddress: selectedTokenFeeAddress,
  feePerUnitGas: selectedRelayer.feePerUnitGas,
}

// Minimum gas limit for the cross-contract call.
// Set an appropriate minimum for your call (1.6M - 3.2M depending on the call).
// If this is set too low, the gas estimate can revert.
const minGasLimit = BigInt(2_400_000); // NOTE: This is provided by recipeOutput.minGasLimit if using the Cookbook

// Whether to use a Relayer or self-signing wallet.
// true for self-signing, false for Relayer.
const sendWithPublicWallet = false;

const railgunWalletID = '...'; // Obtained after a previous call to `createRailgunWallet`

const { gasEstimate } = await gasEstimateForUnprovenCrossContractCalls(
  NetworkName.Ethereum,
  railgunWalletID,
  encryptionKey,
  relayAdaptUnshieldERC20Amounts,
  relayAdaptUnshieldNFTAmounts,
  relayAdaptShieldERC20Recipients,
  relayAdaptShieldNFTRecipients,
  crossContractCalls,
  originalGasDetails,
  feeTokenDetails,
  sendWithPublicWallet,
  minGasLimit,
);

const transactionGasDetails: TransactionGasDetails = {
  evmGasType,
  gasEstimate,
  gasPrice
}

Proof Generation

// See "Unshields ERC-20 tokens" for an example of other required fields.

// ONLY required for transactions that are using a Relayer. Can leave undefined if self-signing.
const overallBatchMinGasPrice: Optional<bigint> = await calculateGasPrice(transactionGasDetails);

await generateCrossContractCallsProof(
    NetworkName.Ethereum,
    railgunWalletID,
    encryptionKey,
    relayAdaptUnshieldERC20Amounts,
    relayAdaptUnshieldNFTAmounts,
    relayAdaptShieldERC20Recipients,
    relayAdaptShieldNFTRecipients,
    crossContractCallsSerialized,
    relayerFeeTokenAmountRecipient,
    sendWithPublicWallet,
    overallBatchMinGasPrice,
    minGasLimit,
    progressCallback,
);

Populate Transaction

// NOTE: Must follow proof generation.
// Use the exact same parameters as proof or this will throw invalid error.

const populateResponse = await populateProvedCrossContractCalls(
    NetworkName.Ethereum,
    railgunWalletID,
    relayAdaptUnshieldERC20Amounts,
    relayAdaptUnshieldNFTAmounts,
    relayAdaptShieldERC20Recipients,
    relayAdaptShieldNFTRecipients,
    crossContractCalls,
    relayerFeeTokenAmountRecipient,
    sendWithPublicWallet,
    overallBatchMinGasPrice,
    gasDetails,
);

// Submit via Relayer with populateResponse output. See "Relayers" section
// or self-sign with any wallet. See "UX: Private Transactions"

Last updated