Skip to main content

Overview

The Permit2 integration flow allows users to swap ERC20 tokens across chains without requiring separate approval transactions. This provides a better user experience by:
  1. Eliminating the need for separate approval transactions
  2. Enabling gasless approvals, saving users gas fees
  3. Only transferring tokens if the auction is completed and a transmitter has picked up the request
If the wallet supports EIP-1271, you can use the Permit2 signature flow for approvals.Otherwise, use the fallback mechanism by sending the request via the BungeeInbox contract.

Integration Steps

Step 1: Get a Quote

Request a quote for your ERC20 transfer using the actual parameters from the script. This ensures you have all the required data for the next steps.
API Reference: Quote Endpoint
const BUNGEE_API_BASE_URL = "https://public-backend.bungee.exchange";

const quoteParamsERC20 = {
  userAddress: account.address,
  receiverAddress: account.address,
  originChainId: 10, // Optimism
  destinationChainId: 42161, // Arbitrum
  inputToken: "0x0b2c639c533813f4aa9d7837caf62653d097ff85", // USDC on Optimism
  outputToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum
  inputAmount: "1000000", // 1 USDC (6 decimals)
};

async function getQuote(params) {
  const url = `${BUNGEE_API_BASE_URL}/api/v1/bungee/quote`;
  const queryParams = new URLSearchParams(params);
  const fullUrl = `${url}?${queryParams}`;
  const response = await fetch(fullUrl);
  const data = await response.json();
  const serverReqId = response.headers.get("server-req-id");

  if (!data.success) {
    throw new Error(
      `Quote error: ${data.statusCode}: ${data.message}. server-req-id: ${serverReqId}`
    );
  }

  if (!data.result.autoRoute) {
    throw new Error(`No autoRoute available. server-req-id: ${serverReqId}`);
  }

  const quoteId = data.result.autoRoute.quoteId;
  const requestType = data.result.autoRoute.requestType;
  let witness = null;
  let signTypedData = null;

  if (data.result.autoRoute.signTypedData) {
    signTypedData = data.result.autoRoute.signTypedData;
    if (signTypedData.values && signTypedData.values.witness) {
      witness = signTypedData.values.witness;
    }
  }

  const approvalData = data.result.autoRoute.approvalData;

  return {
    quoteId,
    requestType,
    witness,
    signTypedData,
    approvalData,
    fullResponse: data,
  };
}

Step 2: Check and Approve Token (ERC20 Approval Step)

Before signing and submitting the request, you must ensure the permit2 contract has sufficient allowance to spend your tokens. The script checks for allowance and sends an approval transaction if needed.
Read more about permit2 here.
async function checkAndApproveToken(approvalData) {
  if (!approvalData || !approvalData.tokenAddress) {
    console.log("No approval data found or required");
    return;
  }

  // ERC20 ABI for allowance and approve functions
  const erc20Abi = [
    {
      inputs: [
        { name: "owner", type: "address" },
        { name: "spender", type: "address" },
      ],
      name: "allowance",
      outputs: [{ name: "", type: "uint256" }],
      stateMutability: "view",
      type: "function",
    },
    {
      inputs: [
        { name: "spender", type: "address" },
        { name: "amount", type: "uint256" },
      ],
      name: "approve",
      outputs: [{ name: "", type: "bool" }],
      stateMutability: "nonpayable",
      type: "function",
    },
  ];

  const currentAllowance = await publicClient.readContract({
    address: approvalData.tokenAddress,
    abi: erc20Abi,
    functionName: "allowance",
    args: [
      approvalData.userAddress,
      approvalData.spenderAddress === "0"
        ? "0x000000000022D473030F116dDEE9F6B43aC78BA3" // Permit2 default
        : approvalData.spenderAddress,
    ],
  });

  if (BigInt(currentAllowance) >= BigInt(approvalData.amount)) {
    console.log("Sufficient allowance already exists.");
    return;
  }

  // Send approval transaction
  const hash = await walletClient.writeContract({
    address: approvalData.tokenAddress,
    abi: erc20Abi,
    functionName: "approve",
    args: [
      approvalData.spenderAddress === "0"
        ? "0x000000000022D473030F116dDEE9F6B43aC78BA3"
        : approvalData.spenderAddress,
      approvalData.amount,
    ],
  });

  console.log(`Approval transaction sent: ${hash}`);
  await publicClient.waitForTransactionReceipt({ hash });
  console.log("Approval confirmed.");
}

Step 3: Sign and Submit the Request

After ensuring approval, sign the typed data and submit the request. Use the actual function and parameter names from the script.
async function viemSignTypedData(signTypedData) {
  const signature = await account.signTypedData({
    types: signTypedData.types,
    primaryType: "PermitWitnessTransferFrom",
    message: signTypedData.values,
    domain: signTypedData.domain,
  });
  return signature;
}

async function submitSignedRequest(
  requestType,
  request,
  userSignature,
  quoteId
) {
  const requestBody = {
    requestType,
    request,
    userSignature,
    quoteId,
  };

  const response = await fetch(`${BUNGEE_API_BASE_URL}/api/v1/bungee/submit`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(requestBody),
  });

  const data = await response.json();

  if (!data.success) {
    throw new Error(`Submit error: ${data.error?.message || "Unknown error"}`);
  }

  return data.result;
}

Step 4: Check Request Status

After submitting the request, check its status to track progress. You can implement a polling mechanism to continuously check until completion:
API Reference: Status Endpoint
async function checkStatus(requestHash) {
  const response = await fetch(
    `${BUNGEE_API_BASE_URL}/api/v1/bungee/status?requestHash=${requestHash}`
  );
  const data = await response.json();

  if (!data.success) {
    throw new Error(`Status error: ${data.error?.message || "Unknown error"}`);
  }

  return data.result[0];
}

async function pollForCompletion(
  requestHash,
  interval = 5000,
  maxAttempts = 60
) {
  let attempts = 0;
  const terminalStates = [3, 4, 5, 6, 7]; // FULFILLED, SETTLED, EXPIRED, CANCELLED, REFUNDED

  while (attempts < maxAttempts) {
    const status = await checkStatus(requestHash);
    const code = status?.bungeeStatusCode;

    if (terminalStates.includes(code)) {
      if (code === 3 || code === 4) {
        console.log("Transaction complete:", status.destinationData?.txHash);
      } else if (code === 5) {
        console.log("Request expired");
      } else if (code === 6) {
        console.log("Request cancelled");
      } else if (code === 7) {
        console.log("Request refunded:", status.refund?.txHash);
      }
      return status;
    }

    attempts++;
    await new Promise((resolve) => setTimeout(resolve, interval));
  }

  throw new Error("Polling timed out. Transaction may not have completed.");
}

Complete Integration Example

import { privateKeyToAccount } from "viem/accounts";
import { createPublicClient, http, createWalletClient } from "viem";
import { optimism } from "viem/chains";
import { logToJson } from "../utils/logger.js";
import path from "node:path";

// Define script-specific output path
const OUTPUT_DIR = path.resolve(
  process.cwd(),
  "scripts/examples/output/auto-erc20"
);

// Check if PRIVATE_KEY is set, set in console, i.e.: export PRIVATE_KEY=<YOUR_PRIVATE_KEY>
if (!process.env.PRIVATE_KEY) {
  console.error("Error: PRIVATE_KEY environment variable is not set");
  console.error(
    "Example: export PRIVATE_KEY=<YOUR_PRIVATE_KEY>"
  );
  process.exit(1);
}

// Create account from private key
// Normalize private key to ensure it has exactly one "0x" prefix (required by viem)
const normalizedPrivateKey = `0x${process.env.PRIVATE_KEY.replace(/^0x/i, '')}`;
const account = privateKeyToAccount(normalizedPrivateKey);

// Create Viem clients
const publicClient = createPublicClient({
  chain: optimism,
  transport: http(),
});

const walletClient = createWalletClient({
  account,
  chain: optimism,
  transport: http(),
});

// API and token parameters
const BUNGEE_API_BASE_URL = "https://public-backend.bungee.exchange";

// Quote parameters for ERC20 token test (USDC from Optimism to Arbitrum)
const quoteParamsERC20 = {
  userAddress: account.address,
  receiverAddress: account.address,
  originChainId: 10, // Optimism
  destinationChainId: 42161, // Arbitrum
  inputToken: "0x0b2c639c533813f4aa9d7837caf62653d097ff85", // USDC on Optimism
  outputToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum
  inputAmount: "1000000", // 1 USDC (6 decimals)
};

// Function to get a quote
async function getQuote(params) {
  try {
    const url = `${BUNGEE_API_BASE_URL}/api/v1/bungee/quote`;
    const queryParams = new URLSearchParams(params);
    const fullUrl = `${url}?${queryParams}`;

    // Log the request parameters
    logToJson(params, "quote_request_params", OUTPUT_DIR);

    const response = await fetch(fullUrl);
    const data = await response.json();
    const serverReqId = response.headers.get("server-req-id");

    // Log the response
    logToJson(data, "quote_response", OUTPUT_DIR);

    // Check for errors
    if (!data.success) {
      throw new Error(
        `Quote error: ${data.statusCode}: ${data.message}. server-req-id: ${serverReqId}`
      );
    }

    // Check if autoRoute exists
    if (!data.result.autoRoute) {
      throw new Error(`No autoRoute available. server-req-id: ${serverReqId}`);
    }

    const quoteId = data.result.autoRoute.quoteId;
    const requestType = data.result.autoRoute.requestType;
    console.log("- Quote ID:", quoteId);
    console.log("- Request Type:", requestType);

    // Extract data based on the response structure
    let witness = null;
    let signTypedData = null;

    if (data.result.autoRoute.signTypedData) {
      signTypedData = data.result.autoRoute.signTypedData;
      // The witness is located in signTypedData.values.witness
      if (signTypedData?.values?.witness) {
        witness = signTypedData.values.witness;
      }
    }

    // Extract approval data if present
    const approvalData = data.result.autoRoute.approvalData;

    // Log request hash
    if (data.result.autoRoute.requestHash) {
      console.log("- Request Hash:", data.result.autoRoute.requestHash);
    }

    // Witness object is available but not logged to keep output clean
    // Log the typed data structure for debugging
    if (signTypedData) {
      logToJson(signTypedData, "typed_data_structure", OUTPUT_DIR);
    }

    return {
      quoteId,
      requestType,
      witness,
      signTypedData,
      approvalData,
      fullResponse: data,
    };
  } catch (error) {
    console.error("Failed to get quote:", error);
    throw error;
  }
}

// Function to sign typed data using viem
async function viemSignTypedData(signTypedData) {
  try {
    const signature = await account.signTypedData({
      types: signTypedData.types,
      primaryType: "PermitWitnessTransferFrom",
      message: signTypedData.values,
      domain: signTypedData.domain,
    });
    console.log("- Signature:", signature);
    return signature;
  } catch (error) {
    console.error("Failed to sign typed data:", error);
    throw error;
  }
}

// Function to submit the signed request
async function submitSignedRequest(
  requestType,
  request,
  userSignature,
  quoteId
) {
  try {
    // Prepare request body
    const requestBody = {
      requestType,
      request,
      userSignature,
      quoteId,
    };

    // Log the request body
    logToJson(requestBody, "submit_request", OUTPUT_DIR);

    const response = await fetch(
      `${BUNGEE_API_BASE_URL}/api/v1/bungee/submit`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(requestBody),
      }
    );

    const data = await response.json();

    // Log the response data
    logToJson(data, "submit_response", OUTPUT_DIR);

    // Response data is saved to file but not logged to keep output clean
    if (!data.success) {
      throw new Error(
        `Submit error: ${data.error?.message || "Unknown error"}`
      );
    }

    console.log("- Request Hash:", data.result.requestHash);
    return data.result;
  } catch (error) {
    console.error("Failed to submit signed request:", error);
    throw error;
  }
}

// Function to check the status of a request
async function checkStatus(requestHash) {
  try {
    // Log the request parameters
    logToJson({ requestHash }, "status_request_params", OUTPUT_DIR);

    const response = await fetch(
      `${BUNGEE_API_BASE_URL}/api/v1/bungee/status?requestHash=${requestHash}`
    );
    const data = await response.json();

    // Log the response data
    logToJson(data, "status_response", OUTPUT_DIR);

    if (!data.success) {
      throw new Error(
        `Status error: ${data.error?.message || "Unknown error"}`
      );
    }

    return data.result[0];
  } catch (error) {
    console.error("Failed to check status:", error);
    throw error;
  }
}

// Function to check and handle token approvals
async function checkAndApproveToken(approvalData) {
  if (!approvalData || !approvalData.tokenAddress) {
    console.log("No approval data found or required");
    return;
  }

  console.log("\nChecking token approval...");

  // ERC20 ABI for allowance and approve functions
  const erc20Abi = [
    {
      inputs: [
        { name: "owner", type: "address" },
        { name: "spender", type: "address" },
      ],
      name: "allowance",
      outputs: [{ name: "", type: "uint256" }],
      stateMutability: "view",
      type: "function",
    },
    {
      inputs: [
        { name: "spender", type: "address" },
        { name: "amount", type: "uint256" },
      ],
      name: "approve",
      outputs: [{ name: "", type: "bool" }],
      stateMutability: "nonpayable",
      type: "function",
    },
  ];

  try {
    const currentAllowance = await publicClient.readContract({
      address: approvalData.tokenAddress,
      abi: erc20Abi,
      functionName: "allowance",
      args: [
        approvalData.userAddress,
        // TODO:Fall back to known Permit2 address if spenderAddress is "0"
        approvalData.spenderAddress === "0"
          ? "0x000000000022D473030F116dDEE9F6B43aC78BA3"
          : approvalData.spenderAddress,
      ],
    });

    console.log(`Current allowance: ${currentAllowance}`);
    console.log(`Required approval: ${approvalData.amount}`);

    // Check if approval is needed
    if (BigInt(currentAllowance) >= BigInt(approvalData.amount)) {
      console.log("Sufficient allowance already exists.");
      return;
    }

    console.log("Insufficient allowance. Approving tokens...");

    // Send approval transaction
    const hash = await walletClient.writeContract({
      address: approvalData.tokenAddress,
      abi: erc20Abi,
      functionName: "approve",
      args: [
        // Fall back to known Permit2 address if spenderAddress is "0"
        approvalData.spenderAddress === "0"
          ? "0x000000000022D473030F116dDEE9F6B43aC78BA3"
          : approvalData.spenderAddress,
        approvalData.amount,
      ],
    });

    console.log(`Approval transaction sent: ${hash}`);

    // Wait for transaction to be mined
    const receipt = await publicClient.waitForTransactionReceipt({ hash });
    console.log(`Approval confirmed in block ${receipt.blockNumber}`);

    return receipt;
  } catch (error) {
    console.error("Error in approval process:", error);
    throw error;
  }
}

// Main function to handle the flow
async function main() {
  try {
    console.log("Starting Bungee ERC20 Token Test (Auto Route)...");

    // Get the quote and extract data
    console.log("\n1. Getting quote...");
    const quoteResponse = await getQuote(quoteParamsERC20);
    const { quoteId, requestType, witness, signTypedData } = quoteResponse;

    // Check if approval is needed and handle it
    if (quoteResponse.approvalData) {
      await checkAndApproveToken(quoteResponse.approvalData);
    }

    if (signTypedData && witness) {
      // Sign the typed data
      console.log("\n2. Signing typed data...");
      const signature = await viemSignTypedData(signTypedData);

      // Submit the signed request
      console.log("\n3. Submitting signed request...");
      const submitResult = await submitSignedRequest(
        requestType,
        witness,
        signature,
        quoteId
      );

      console.log(
        "\n4. Submission complete:",
        "\n- Hash:",
        submitResult.requestHash,
        "\n- Type:",
        submitResult.requestType
      );

      // Check the status
      // Wait for 5 seconds
      console.log("\n4. Waiting for 5 seconds...");
      let status;
      const terminalStates = [3, 4, 5, 6, 7]; // FULFILLED, SETTLED, EXPIRED, CANCELLED, REFUNDED
      do {
        await new Promise((resolve) => setTimeout(resolve, 5000));
        console.log("\n5. Checking status...");
        try {
          status = await checkStatus(submitResult.requestHash);
          console.log("- Status details:", status.bungeeStatusCode);
        } catch (error) {
          console.error(
            "Failed to check status:",
            error?.message || "Unknown error"
          );
        }
      } while (!status || !terminalStates.includes(status?.bungeeStatusCode));

      // Handle terminal states
      if (status) {
        const code = status.bungeeStatusCode;
        if (code === 3 || code === 4) {
          console.log(
            "\n6. Transaction complete:",
            "\n- Hash:",
            status.destinationData?.txHash || "Transaction hash not available"
          );
        } else if (code === 5) {
          console.error("\n6. Request expired");
          throw new Error(`Request expired. Status: ${JSON.stringify(status)}`);
        } else if (code === 6) {
          console.error("\n6. Request cancelled");
          throw new Error(`Request cancelled. Status: ${JSON.stringify(status)}`);
        } else if (code === 7) {
          console.error("\n6. Request refunded:", status.refund?.txHash || "Refund transaction hash not available");
          throw new Error(`Request refunded. Status: ${JSON.stringify(status)}`);
        }
      }
    } else {
      console.log("No signature data available in the quote response");
    }
  } catch (error) {
    console.error("Error in processing:", error?.shortMessage || error.message);
    throw error;
  }
}

// Execute the main function
main();