On this page

Transfer from Starknet

Use the Starknet Omnisea entrypoint to move a canonical ERC-20 or an existing Omniasset to a supported EVM chain. Starknet transfers pay both the LayerZero messaging fee and the flat Omnisea fee in STRK.

Supported destinations

DestinationLayerZero EID
Ethereum30101
Base30184
Arbitrum30110
Polygon30109
BNB Chain30102
Avalanche30106
Optimism30111

Starknet routes to Robinhood Chain and Stellar are not active. Reject those EIDs in your application before quoting.

1. Determine the transfer mode

Call is_representation on OmniseaRegistry for the source token:

const result = await provider.callContract({
  contractAddress: STARKNET_REGISTRY,
  entrypoint: "is_representation",
  calldata: [token],
});

const isRepresentation = BigInt(result[0]) === 1n;
  • Canonical Starknet token: quote with quote_send_original_to, then send with send_original_to.
  • Omniasset representation: quote with quote_send_oft_to, then send with send_oft_to.

For a canonical Starknet token, encode its address as 32 raw big-endian bytes and call representationFor(30500, tokenBytes) on the destination EVM Omnisea contract. Use isFirstTransfer = true only when that lookup returns the zero address. A representation returning to its original chain always uses false.

2. Build the LayerZero options

Use 900000 destination gas when the transfer must create a representation and 500000 for an existing representation or an unlock:

function u128(value: bigint) {
  return value.toString(16).padStart(32, "0");
}

function lzReceiveOptions(gas: bigint) {
  return `0x000301002101${u128(gas)}${u128(0n)}` as `0x${string}`;
}

const options = lzReceiveOptions(isFirstTransfer ? 900_000n : 500_000n);

Read Executor options before adding destination composition.

3. Quote, approve, and send

Install starknet, connect a Starknet account supplied by the user's wallet, then execute the approvals and send atomically. The recipient is the destination EVM account encoded as its raw 20 bytes.

npm install starknet
import type { Call, RpcProvider, WalletAccount } from "starknet";

const STARKNET_OMNISEA =
  "0x04e8d3ece237d1d2a274e8f5c2fe899108bde1d8a2da90ca08285afb5f24ef0b";
const STARKNET_ROUTER =
  "0x076be43f9bfa26c67aed7ee5d2907fbd02c52bb33e171f7f802f2e606bfef534";
const STRK =
  "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";

type TransferInput = {
  account: WalletAccount;
  provider: RpcProvider;
  token: string;
  amount: bigint;
  destinationEid: number;
  evmRecipient: `0x${string}`;
  isRepresentation: boolean;
  isFirstTransfer: boolean;
};

async function transferFromStarknet(input: TransferInput) {
  if (!/^0x[0-9a-fA-F]{40}$/.test(input.evmRecipient)) {
    throw new Error("Recipient must be a 20-byte EVM address.");
  }

  const options = lzReceiveOptions(input.isFirstTransfer ? 900_000n : 500_000n);
  const commonParams = serializeCommonParams({
    destinationEid: input.destinationEid,
    amount: input.amount,
    recipient: input.evmRecipient,
    isFirstTransfer: input.isFirstTransfer,
    options,
  });
  const quoteEntrypoint = input.isRepresentation
    ? "quote_send_oft_to"
    : "quote_send_original_to";
  const sendEntrypoint = input.isRepresentation
    ? "send_oft_to"
    : "send_original_to";

  const rawQuote = await input.provider.callContract({
    contractAddress: STARKNET_OMNISEA,
    entrypoint: quoteEntrypoint,
    calldata: [feltAddress(input.token), ...commonParams, "0"],
  });
  if (rawQuote.length < 6) throw new Error("Invalid Omnisea quote.");

  const nativeFee = fromU256(rawQuote, 0);
  const lzTokenFee = fromU256(rawQuote, 2);
  const protocolFee = fromU256(rawQuote, 4);
  const approvals = new Map<string, bigint>();

  if (!input.isRepresentation) {
    addApproval(approvals, input.token, STARKNET_OMNISEA, input.amount);
  }
  addApproval(approvals, STRK, STARKNET_OMNISEA, protocolFee);
  addApproval(approvals, STRK, STARKNET_ROUTER, nativeFee);

  const calls: Call[] = [...approvals.entries()].map(([key, amount]) => {
    const [token, spender] = key.split(":");
    return {
      contractAddress: token,
      entrypoint: "approve",
      calldata: [spender, ...toU256(amount)],
    };
  });

  calls.push({
    contractAddress: STARKNET_OMNISEA,
    entrypoint: sendEntrypoint,
    calldata: [
      feltAddress(input.token),
      ...commonParams,
      ...toU256(nativeFee),
      ...toU256(lzTokenFee),
      feltAddress(input.account.address),
    ],
  });

  const response = await input.account.execute(calls);
  await input.provider.waitForTransaction(response.transaction_hash);
  return response.transaction_hash;
}

quote_send_* returns the LayerZero native fee, optional LZ-token fee, and protocol fee separately. The transaction approves the canonical token to the core, the protocol fee to the core, and the LayerZero fee to the Router. When the canonical token itself is STRK, addApproval combines the two core allowances.

Calldata helpers

Starknet's ABI represents raw cross-VM addresses and LayerZero options as Cairo ByteArray values. These helpers serialize the same calldata used by the Omnisea web application:

const U128_MASK = (1n << 128n) - 1n;
const STARKNET_FIELD_PRIME = (1n << 251n) + 17n * (1n << 192n) + 1n;

function serializeCommonParams(input: {
  destinationEid: number;
  amount: bigint;
  recipient: `0x${string}`;
  isFirstTransfer: boolean;
  options: `0x${string}`;
}) {
  return [
    String(input.destinationEid),
    ...toU256(input.amount),
    ...serializeByteArray(input.recipient),
    input.isFirstTransfer ? "1" : "0",
    ...serializeByteArray("0x"), // composer
    "0",                         // compose gas_limit
    ...serializeByteArray("0x"), // compose message
    ...serializeByteArray(input.options),
  ];
}

function serializeByteArray(value: string) {
  const hex = value.startsWith("0x") ? value.slice(2) : value;
  if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
    throw new Error("Invalid raw bytes.");
  }

  const bytes = Uint8Array.from(
    hex.match(/.{2}/g)?.map((byte) => Number.parseInt(byte, 16)) ?? [],
  );
  const fullWordCount = Math.floor(bytes.length / 31);
  const output = [String(fullWordCount)];

  for (let index = 0; index < fullWordCount; index += 1) {
    output.push(bytesToFelt(bytes.slice(index * 31, (index + 1) * 31)));
  }

  const pending = bytes.slice(fullWordCount * 31);
  output.push(pending.length ? bytesToFelt(pending) : "0x0", String(pending.length));
  return output;
}

function addApproval(
  approvals: Map<string, bigint>,
  token: string,
  spender: string,
  amount: bigint,
) {
  if (amount === 0n) return;
  const key = `${feltAddress(token)}:${feltAddress(spender)}`;
  approvals.set(key, (approvals.get(key) ?? 0n) + amount);
}

function toU256(value: bigint) {
  return [(value & U128_MASK).toString(), (value >> 128n).toString()];
}

function fromU256(values: readonly string[], offset: number) {
  return BigInt(values[offset]) + (BigInt(values[offset + 1]) << 128n);
}

function feltAddress(value: string) {
  const address = BigInt(value);
  if (address <= 0n || address >= STARKNET_FIELD_PRIME) {
    throw new Error("Invalid Starknet address.");
  }
  return `0x${address.toString(16).padStart(64, "0")}`;
}

function bytesToFelt(bytes: Uint8Array) {
  const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
  return `0x${hex || "0"}`;
}

Send to Starknet from EVM

Use the byte-recipient EVM functions quoteSendOriginalTo / sendOriginalTo or quoteSendOFTTo / sendOFTTo. Encode the Starknet recipient as exactly 32 big-endian bytes:

bytes memory starknetRecipient = abi.encodePacked(bytes32(uint256(starknetAccount)));

uint256 fee = omnisea.quoteSendOriginalTo(
    30500, token, amount, starknetRecipient,
    isFirstTransfer, compose, options
);

omnisea.sendOriginalTo{value: fee}(
    30500, token, amount, starknetRecipient,
    isFirstTransfer, compose, options
);

Use at least 1,500,000 units of lzReceive gas when Starknet is the destination, whether the representation already exists or must be created.

The quote and send parameters must remain byte-identical. Follow Bridge an ERC-20 for approvals, destination checks, tracking, and recovery.

Experimental Beta is Live-Learn more about the Pilot