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
| Destination | LayerZero EID |
|---|---|
| Ethereum | 30101 |
| Base | 30184 |
| Arbitrum | 30110 |
| Polygon | 30109 |
| BNB Chain | 30102 |
| Avalanche | 30106 |
| Optimism | 30111 |
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 withsend_original_to. - Omniasset representation: quote with
quote_send_oft_to, then send withsend_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 starknetimport 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.