When you set fee: 0n and nativeFee: 0n, relayers won’t process your transfer. You finalize it yourself on the destination chain.
When to use this:
- Cost savings on high-volume transfers
- Running your own relayer infrastructure
- Specific timing or ordering requirements
The Process
The steps depend on your source chain:
From NEAR:
- Initiate transfer (
ft_transfer_call)
- Call
signTransfer to trigger MPC signing
- Parse the
SignTransferEvent from logs
- Submit finalization on destination chain
From EVM/Solana:
- Initiate transfer on source chain
- Wait for proof to become available (Merkle proof or Wormhole VAA)
- Submit finalization on NEAR
The proof type and wait time depends on the source:
| Source | Proof Type | Wait Time |
|---|
| NEAR | MPC Signature | ~1-5 min |
| Ethereum | Merkle Proof | ~15-20 min |
| L2s (Base, Arb, etc.) | Wormhole VAA | ~1 min |
| Solana | Wormhole VAA | ~15 sec |
When is Finalization Ready?
Poll the API for the Signed status:
import { BridgeAPI } from "@omni-bridge/core"
const api = new BridgeAPI("mainnet")
const statuses = await api.getTransferStatus({ transactionHash: hash })
if (statuses.includes("Signed")) {
// Ready to finalize!
}
For Ethereum → NEAR specifically, you need to wait for the light client to sync the block (check Initialized status and wait ~15-20 min).
From NEAR to EVM
NEAR → EVM transfers require three steps: initiate, sign, then finalize.
Step 1: Initiate the Transfer
import { createBridge } from "@omni-bridge/core"
import { createNearBuilder, toNearKitTransaction } from "@omni-bridge/near"
const bridge = createBridge({ network: "mainnet" })
const nearBuilder = createNearBuilder({ network: "mainnet" })
const signerId = "alice.near"
// Validate and build the transfer
const validated = await bridge.validateTransfer({
token: "near:wrap.near",
amount: 1_000_000_000_000_000_000_000_000n,
sender: `near:${signerId}`,
recipient: "eth:0x...",
fee: 0n,
nativeFee: 0n,
})
const initTx = nearBuilder.buildTransfer(validated, signerId)
const initResult = await toNearKitTransaction(near, initTx).send()
Step 2: Sign the Transfer
After the transfer is initialized, you must call signTransfer to trigger MPC signing:
// Extract the transfer ID from the InitTransferEvent in the logs
const initEvent = parseInitTransferEvent(initResult)
const signTx = nearBuilder.buildSignTransfer(
{
origin_chain: "Near",
origin_nonce: initEvent.nonce,
},
signerId
)
const signResult = await toNearKitTransaction(near, signTx).send()
Step 3: Finalize on EVM
Parse the SignTransferEvent from the sign transaction logs and finalize on EVM:
import { createEvmBuilder } from "@omni-bridge/evm"
import { ChainKind } from "@omni-bridge/core"
import { MPCSignature } from "@omni-bridge/near"
const evm = createEvmBuilder({ network: "mainnet", chain: ChainKind.Eth })
// Parse SignTransferEvent from signResult logs
const signEvent = parseSignTransferEvent(signResult)
// Convert signature for EVM (adds 27 to recovery ID)
const signature = MPCSignature.fromSignTransferEvent(signEvent).toBytes(true)
const tx = evm.buildFinalization(signEvent.message_payload, signature)
await walletClient.sendTransaction(tx)
The MPC signature needs format conversion for EVM — use MPCSignature.toBytes(true) to add 27 to the recovery ID. For Solana, use toBytes(false).
From EVM to NEAR
Ethereum (Merkle Proof)
Ethereum uses the NEAR light client for verification:
import { createNearBuilder, toNearKitTransaction } from "@omni-bridge/near"
import { getEvmProof, ProofKind } from "@omni-bridge/evm"
const nearBuilder = createNearBuilder({ network: "mainnet" })
// Wait for light client to sync (~15-20 min after source tx confirms)
// Generate Merkle proof
const proof = await getEvmProof(txHash, ChainKind.Eth)
// Serialize for NEAR
const proverArgs = nearBuilder.serializeEvmProofArgs({
proof_kind: ProofKind.InitTransfer,
proof,
})
// Build and send finalization
const tx = nearBuilder.buildFinalization(ChainKind.Eth, proverArgs, signerId)
await toNearKitTransaction(near, tx).send()
L2s (Wormhole VAA)
Base, Arbitrum, Polygon, and BNB use Wormhole:
import { getWormholeVaa } from "@omni-bridge/core"
// Wait for Wormhole guardians to sign (~1 min)
const vaa = await getWormholeVaa(txSignature, "Mainnet")
// Serialize for NEAR
const proverArgs = nearBuilder.serializeWormholeProofArgs({
proof_kind: ProofKind.InitTransfer,
vaa,
})
// Finalize
const tx = nearBuilder.buildFinalization(ChainKind.Base, proverArgs, signerId)
await toNearKitTransaction(near, tx).send()
From Solana to NEAR
Solana also uses Wormhole VAAs:
import { getWormholeVaa } from "@omni-bridge/core"
const vaa = await getWormholeVaa(solanaSignature, "Mainnet")
const proverArgs = nearBuilder.serializeWormholeProofArgs({
proof_kind: ProofKind.InitTransfer,
vaa,
})
const tx = nearBuilder.buildFinalization(ChainKind.Sol, proverArgs, signerId)
await toNearKitTransaction(near, tx).send()
Finalization Builders
Each destination has a buildFinalization method:
| Destination | Method |
|---|
| EVM | evmBuilder.buildFinalization(payload, signature) |
| NEAR | nearBuilder.buildFinalization(chain, proverArgs, signerId) |
| Solana | solanaBuilder.buildFinalization(payload, signature, payer) |
Working Examples
For complete, runnable examples:
Consider Using Relayers Instead
Manual finalization adds complexity. Use relayer fees if:
- You want fire-and-forget simplicity
- The fee cost is acceptable
- You don’t need precise timing control