Bridge
The Alcor bridge moves tokens between Ethereum, BNB Smart Chain, Polygon, Telos, WAX, XPR Network and Vaulta. For partners the interesting route is Ethereum → WAX. The user signs one transaction on Ethereum and the bridge delivers the tokens to their WAX account by itself: there is nothing to claim and no WAX wallet involved. Small deposits usually arrive in 13–20 minutes. The live estimate is in Chains.
There are four ways to integrate it:
- Deposit link: send users to the Alcor bridge page with the transfer prefilled.
- Deposit widget: open the bridge over your own page in a modal, popup or inline block.
- Bridge API: read routes, limits, timing and transfer status yourself.
- Bridge contracts: call the contracts from your own code or bot, with no Alcor page involved.
Supported assets are USDC, USDT, ETH, BNB, POL, WAX, XPR and A (Vaulta). The authoritative list, with minimums and maximums, is always Routes.
Deposit link
https://alcor.exchange/v/wax/bridge?from=ethereum&to=wax&recipient=alice.wam&token=USDC&amount=50&partner=mygame&return_url=https://mygame.io/playA plain URL to the bridge page with the transfer already filled in. No code is needed on your side.
Query Parameters
| Parameter | Example | Description |
|---|---|---|
| from | ethereum | Chain to start on. The user can still change it. |
| to | wax | Chain the tokens land on. |
| recipient | alice.wam | Receiving account, prefilled. The user can edit it on this page. |
| token | USDC | Asset, by the symbol the bridge lists (USDC, USDT, ETH). |
| amount | 50 | Prefilled amount, in token units. |
| partner | mygame | Your id, so the deposits you bring are attributed to you. |
| return_url | https://mygame.io/play | Shows a "← Back to mygame.io" link. https only (http is allowed on localhost). |
All parameters are optional. A malformed value is ignored rather than guessed at.
Deposit widget
// <script src="https://alcor.exchange/sdk/bridge/v1.js"></script>
document.getElementById('deposit').onclick = function () {
AlcorBridge.open({
to: 'wax',
recipient: 'alice.wam', // fixed: the user cannot change it
token: 'USDC',
amount: '50',
partner: 'mygame',
onSent: function (e) { console.log('on its way', e.transactions) },
onFinished: function (e) {
if (e.outcome === 'delivered') refreshBalance()
},
onClose: function () {},
})
}<!-- Without the script: embed the widget yourself -->
<iframe src="https://alcor.exchange/embed/bridge?to=wax&recipient=alice.wam&token=USDC&partner=mygame&mode=inline"
style="width:100%;max-width:480px;height:640px;border:0"></iframe>Load https://alcor.exchange/sdk/bridge/v1.js and call one of its functions.
The script is tiny and has no dependencies. The path is versioned:
v1.js keeps its options and events for as long as anyone uses it.
Functions
| Function | Shows | Returns |
|---|---|---|
AlcorBridge.open(options) | Modal over your page | { close() } |
AlcorBridge.popup(options) | Separate window. Call it from a click handler. | { close() }, or null if the popup was blocked |
AlcorBridge.mount(element, options) | Inline iframe that grows with its content | { destroy() } |
AlcorBridge.url(options) | Nothing. Returns the widget address for your own iframe or link. | string |
Browser extension wallets (MetaMask, Rabby) and WalletConnect work in all modes. In the popup the user's existing Alcor session and wallet connection carry over.
Options
| Option | Required | Description |
|---|---|---|
| to | true | Chain the tokens land on: wax, telos or ethereum. Into WAX the bridge pays out by itself. A deposit to Telos ends with a Collect step signed on Telos, and a withdrawal to Ethereum with a Release step signed on Ethereum. The widget shows that button when its turn comes. |
| recipient | false | Receiving account. When given, it is fixed in the widget. For WAX, the widget checks that the account exists before anything is signed. |
| from | false | Chain to start on. Defaults to ethereum, or telos when to is Ethereum. |
| token | false | USDC, USDT, ETH. |
| amount | false | Prefilled amount, in token units. |
| partner | false | Your id, for attribution. |
| origin | false | Where the widget is served from. Defaults to the origin of the script itself. |
| onReady, onSent, onFailed, onFinished, onClose | false | Event callbacks, see below. |
Events
onSent receives:
{
"direction": "hop",
"from_chain": "ethereum",
"to_chain": "wax",
"token": "USDC",
"amount": "50",
"sender": "0x285202c8db763db06ab42e0705104cb7919ef95e",
"recipient": "alice.wam",
"partner": "mygame",
"embed": "iframe",
"transactions": [
{ "chain": "Ethereum", "label": "Send", "ref": "0x0af8…2917", "url": "https://etherscan.io/tx/0x0af8…2917" }
]
}onFinished receives:
{
"key": "d:1:12",
"outcome": "delivered",
"token": "USDC",
"amount": "50",
"transactions": [ … ]
}| Callback | When | Data |
|---|---|---|
| onReady | The widget has loaded and is prefilled | { to, recipient } |
| onSent | The user signed and the transfer is on its way | Transfer details and the first transaction |
| onFailed | The transfer could not be started, e.g. rejected in the wallet | Transfer details plus errorCode, error |
| onFinished | The transfer reached its end | { key, outcome, token, amount, transactions } |
| onClose | The user closed the widget | {} |
outcome is delivered, or returned if the destination refused the tokens
and they went back to the sender. key identifies the transfer in
Transfer status.
Credit nothing in your app on onSent. Wait for outcome: "delivered", or better, check the balance on chain. onFinished only fires while the widget is open. A transfer the user walked away from still completes, and you can follow it with Transfer status.
Messages
If you embed the iframe yourself, listen for postMessage events from
https://alcor.exchange:
{ source: "alcor-bridge", version: 1, type, data }
| Type | Data |
|---|---|
| ready, sent, failed, finished, close | Same as the callbacks above |
| resize | { height }, the content height in pixels, so you can size the iframe |
Add mode=inline to the widget URL to hide its close button. Everything in the
messages is public on-chain data.
Bridge API
Read-only HTTP API of the bridge indexer, open to any origin (CORS *).
The full reference, every endpoint and field, is served by the indexer itself:
telos.alcor.exchange/api/bridge/docs.
https://telos.alcor.exchange/api/bridge/v1/
Chains are named by id: mainnet (Ethereum), bsc (BNB Smart Chain), polygon (Polygon), telos (Telos), wax (WAX), xpr (XPR Network), vaulta (Vaulta).
Amounts are integers in the asset's smallest unit. Divide by 10^precision.
Run your own indexer
The indexer is open source: github.com/alcorexchange/alcor-bridge-indexer (MIT).
It holds no keys and sends no transactions, and every proof it serves is
verified on chain, so your own instance gives you independence and your own
rate limits. It needs a Telos node with state-history; the README covers the
rest: cp .env.example .env, docker compose up -d.
Routes
curl https://telos.alcor.exchange/api/bridge/v1/routes{
"routes": [
{
"symbol": "USDC",
"precision": 6,
"from": "mainnet",
"to": "wax",
"via": "telos",
"open": true,
"min": "5000000",
"max": "98735000000",
"how": {
"deposit": {
"token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"telosTo": "hop.alcor",
"memo": "1181148696416462999:<account>|1:<your address>"
}
}
}
]
}Every way each asset can travel right now, with its limits.
HTTP Request
GET https://telos.alcor.exchange/api/bridge/v1/routes
Response
| Name | Type | Description |
|---|---|---|
| symbol | string | Asset symbol |
| precision | number | Decimals of min and max |
| from, to | string | Chain ids of both ends |
| via | string | Chain the transfer passes through, or null for a direct route |
| open | boolean | Whether the route accepts transfers now |
| min, max | string | Amount limits, in the smallest unit |
| how | object | How the transfer is started. Used by the widget, and by your own code if you send the transaction yourself. |
Chains
curl https://telos.alcor.exchange/api/bridge/v1/chainsRelevant part of the response:
{
"sources": [
{
"id": "mainnet",
"name": "Ethereum",
"chainId": 1,
"timing": {
"deposit": {
"seconds": 990,
"range": [768, 1212],
"measured": true,
"human": "about 17 min (13 min–20 min)"
}
}
}
]
}State of each chain the bridge reaches, including how long a transfer from it currently takes. Timing is measured on recent transfers, not configured.
HTTP Request
GET https://telos.alcor.exchange/api/bridge/v1/chains
Response
| Name | Type | Description |
|---|---|---|
| sources[].id | string | Chain id |
| sources[].name | string | Display name |
| sources[].timing.deposit.seconds | number | Typical time for a deposit from this chain |
| sources[].timing.deposit.range | number[] | Fastest and slowest recent times, in seconds |
| sources[].timing.deposit.measured | boolean | true when measured on recent transfers |
| sources[].timing.deposit.human | string | The same, phrased for people |
Transfer status
curl https://telos.alcor.exchange/api/bridge/v1/hops/d:1:12
curl "https://telos.alcor.exchange/api/bridge/v1/hops?party=alice.wam"{
"key": "d:1:12",
"status": "delivered",
"progress": {
"stage": "delivered on WAX",
"percent": 100,
"etaSeconds": null
},
"from": { "chain": "mainnet", "address": "0x285202c8db763db06ab42e0705104cb7919ef95e" },
"to": { "chain": "wax", "recipient": "alice.wam" },
"back": { "chain": "mainnet", "recipient": "0x285202c8db763db06ab42e0705104cb7919ef95e" },
"deposit": {
"symbol": "USDC",
"amount": "50000000",
"precision": 6,
"src_tx": "0x0af8049909488c9ebb129dbc30db39ca0c7dc7009978756a8562e38970f92917"
}
}A transfer from Ethereum to WAX, by its key (the key from the widget's
onFinished), or every transfer an account or address took part in. The list
form returns { "hops": [ … ] }, newest first.
HTTP Request
GET https://telos.alcor.exchange/api/bridge/v1/hops/<key>
GET https://telos.alcor.exchange/api/bridge/v1/hops?party=<account or address>
Status
| Status | Meaning |
|---|---|
| arriving | Locked on Ethereum, waiting for Ethereum to finalize |
| sending | Passed through Telos, on its way to the destination |
| delivered | Paid out to the recipient |
| bounced | Refused by the destination, sent back |
| returning | Coming back to the sender |
| returned | Back with the sender |
Bridge contracts
Everything the widget does is a public contract call. Bots, wallets and exchanges can make the same calls themselves: no API key, no allowlist, no Alcor page involved.
The full guide for bots, with every chain's transaction, how to follow a transfer and what can go wrong: telos.alcor.exchange/api/bridge/docs/bot.
How the bridge is built:
- Telos holds the ledger,
bridge.alcor. Every route runs between Telos and one other chain. Ethereum ↔ WAX is two legs, joined by thehop.alcorcontract on Telos. - A deposit is locked in the vault on its chain, proved on Telos and credited there. The bridge's relayer pushes the proof, so you send one transaction.
- A withdrawal is burned on Telos and proved on the destination chain. Into WAX the relayer pays it out. Into Ethereum or BSC you send the proof yourself, in one transaction (see Release on Ethereum or BSC).
- Keys name each leg:
d:<domain>:<nonce>for a deposit,w:<id>for a withdrawal. Every endpoint of the Bridge API takes them.
Addresses
| Chain | Contract | Address | What it is |
|---|---|---|---|
| Ethereum | AlcorVault | 0x3e447d533321ad6a8412f97034ac295a9ff8d858 | Deposits in, withdrawals paid out |
| Ethereum | AlcorWithdrawals | 0x04fb700b93eb68cadd88d8e8d4dfe4857d5cb3c6 | Verifies a withdrawal proof and tells the vault to pay |
| Ethereum | USDC, USDT | 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48, 0xdac17f958d2ee523a2206206994597c13d831ec7 | ETH is native: token 0x0000000000000000000000000000000000000000 |
| BSC | AlcorVault | 0x53f18eaa8bf8099b5ba21bb7e11ed311b677690e | Same contract as on Ethereum |
| BSC | AlcorWithdrawals | 0x6132b4d02e02f1b378f0fc67c3d4f553eb76acb5 | Same contract as on Ethereum. BNB is native |
| Telos | bridge.alcor | The ledger: credits deposits, opens withdrawals | |
| Telos | wrap.alcor | Bridged tokens on Telos: USDC and USDT (6 decimals), ETH, BNB and WAX (8) | |
| Telos | hop.alcor | Carries a transfer between two chains through Telos | |
| WAX | bridge.alcor | The vault on WAX: holds WAX, mints and burns the mirrors | |
| WAX | wrap.alcor | Mirror tokens on WAX: USDC, USDT, ETH, BNB. WAX itself is eosio.token |
ABIs of the EVM contracts:
AlcorVault.json,
AlcorWithdrawals.json.
The Antelope contracts publish theirs on chain (get_abi).
Domains and units
| Chain | Domain |
|---|---|
| Ethereum | 1 |
| BSC | 56 |
| WAX | 1181148696416462999 (first 8 bytes of the WAX chain id) |
The domain names the far chain in every memo and key. It is a decimal string in the API: the WAX one is larger than a JSON number holds exactly.
Amounts. The ledger counts in Telos units (the token's precision on
Telos). On an EVM chain, amount = canonical × scale: scale is 1 for USDC
and USDT and 10^10 for ETH and BNB (18 decimals there, 8 on Telos). A
remainder below scale is not taken: an ERC-20 deposit pulls only
canonical × scale, and the excess of a native deposit is sent back in the
same transaction.
Telos accounts on EVM. telosTo is an Antelope account name packed into a
uint64, the standard Antelope encoding. With
wharfkit: BigInt(Name.from('hop.alcor').value.toString()).
Deposit from Ethereum or BSC
import { Contract, toUtf8Bytes, hexlify } from 'ethers' // ethers v6
import { Name } from '@wharfkit/antelope'
const API = 'https://telos.alcor.exchange/api/bridge/v1'
const vaultAbi = await (await fetch('https://api.alcor.exchange/abi/AlcorVault.json')).json()
const { sources } = await (await fetch(`${API}/chains`)).json()
const vault = new Contract(sources.find(s => s.id === 'mainnet').contracts.vault, vaultAbi, signer)
const telosName = n => BigInt(Name.from(n).value.toString())
const me = await signer.getAddress()
// ETH to the WAX account alice.wam, back to me if WAX never pays
const memo = hexlify(toUtf8Bytes(`1181148696416462999:alice.wam|1:${me}`))
await vault.depositNative(telosName('hop.alcor'), me, 0, 0, memo, { value: 10n ** 16n })
// 50 USDC to the Telos account alice (approve the vault first)
const USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'
await vault.deposit(USDC, 50_000000n, telosName('alice'), me, 0, 0, '0x')deposit(token, amount, telosTo, refundTo, fillFee, filler, memo) for an
ERC-20, after approve(vault, amount).
depositNative(telosTo, refundTo, fillFee, filler, memo) payable, for ETH or BNB.
| Parameter | Type | Description |
|---|---|---|
| token | address | ERC-20 address from Routes (how.deposit.token) |
| amount | uint256 | Raw token units |
| telosTo | uint64 | Who receives on Telos: an account, or hop.alcor to go on to another chain |
| refundTo | address | Where the deposit goes back if it cannot be delivered. Must not be zero |
| fillFee, filler | uint64 | Reserved. Pass 0 |
| memo | bytes | Up to 256 bytes, see below |
Where it lands depends on telosTo and memo:
- A Telos account, empty memo: credited to that account on the ledger. The owner collects it with
bridge.alcor::claim(see Telos actions). - A Telos account or contract, with a memo: delivered to it as a
wrap.alcortransfer carrying that memo. This is how a deposit pays a contract. - To WAX:
telosTo = hop.alcor, memo<WAX domain>:<WAX account>|<this chain's domain>:<your address>. The first half is where it goes, the second where it comes back if WAX never pays it. The tokens arrive on WAX by themselves.
Check that the WAX account exists before you deposit. The bridge cannot see WAX accounts: tokens sent to a missing one come back only after the withdrawal deadline, about a week.
USDT on Ethereum refuses to change a non-zero allowance: set it to 0 first.
A deposit the vault refuses reverts with a custom error from the ABI:
DepositsPaused, AssetDisabled, BelowMinimum(minDeposit), CapExceeded,
AmountTooSmall, MemoTooLong, ZeroAccount, ZeroRefundAddress,
UseDepositNative (the native coin through deposit).
The Deposit event gives the key: d:<chainId>:<nonce>.
Deposit from WAX
# 100 USDC from WAX to the Telos account alice
cleos -u https://wax.greymass.com push action wrap.alcor transfer \
'["bob.wam", "bridge.alcor", "100.000000 USDC", "alice"]' -p bob.wam
# 100 USDC from WAX to an Ethereum address
cleos -u https://wax.greymass.com push action wrap.alcor transfer \
'["bob.wam", "bridge.alcor", "100.000000 USDC", "hop.alcor:1:0x285202c8db763db06ab42e0705104cb7919ef95e|1181148696416462999:bob.wam"]' -p bob.wam// wharfkit session on WAX
await session.transact({
action: {
account: 'wrap.alcor', name: 'transfer',
authorization: [session.permissionLevel],
data: { from: session.actor, to: 'bridge.alcor', quantity: '100.000000 USDC', memo: 'alice' },
},
})A plain transfer to bridge.alcor on WAX. The token is the route's
antelopeToken in Chains: eosio.token for WAX, wrap.alcor for
the mirrors. The memo says where it goes:
<telos account>: credited on Telos, collected withclaim.<telos account>:<note>: delivered to that account as a transfer with<note>as its memo.hop.alcor:<domain>:0x<address>|1181148696416462999:<your WAX account>: lands on Ethereum (1) or BSC (56). The last leg is a withdrawal you release yourself, see Release on Ethereum or BSC. The second half is where it comes back if that leg is never released.
The deposit's key is d:1181148696416462999:<nonce>, the nonce from the
vault's deplog action in your transaction.
Withdraw from Telos
# 50 USDT from Telos to an Ethereum address
cleos -u https://mainnet.telos.net push action wrap.alcor transfer \
'["alice", "bridge.alcor", "50.000000 USDT", "1:0x285202c8db763db06ab42e0705104cb7919ef95e"]' -p alice
# 10 WAX from Telos to the WAX account bob.wam
cleos -u https://mainnet.telos.net push action wrap.alcor transfer \
'["alice", "bridge.alcor", "10.00000000 WAX", "1181148696416462999:bob.wam"]' -p aliceA transfer of a wrap.alcor token to bridge.alcor on Telos, with the
destination in the memo: <domain>:<recipient>[:<fee>].
- Into WAX the relayer pays it out, in about two minutes. Nothing else to do.
- Into Ethereum or BSC the withdrawal waits for you to release it on that chain, see the next section.
feeis optional, a whole number of Telos units out of the amount. It goes to whoever sends the release, so a recipient with no gas on the destination chain can still be paid by someone else. Without it, the fee is zero.
The amount must not exceed withdrawableNow of the route in Chains:
each vault lets only so much out per day.
To attach data for the receiver, use wrap.alcor::withdraw(owner, quantity, domain, recipient, fee, memo)
instead of a transfer: recipient is the 20 address bytes, fee an asset in
the same token, and memo up to 1024 bytes. The memo is proved with the
amount and comes out in the Released event of AlcorWithdrawals.
The withdrawal id is in the inline bridge.alcor::wdlog action of your
transaction: wdlog(id, domain, vault, token, recipient, amount, deadline, fee, memo).
The key is w:<id>.
Release on Ethereum or BSC
const wdAbi = await (await fetch('https://api.alcor.exchange/abi/AlcorWithdrawals.json')).json()
const p = await (await fetch(`${API}/proofs/w:${id}`)).json() // 404 until the proof exists
if (p.track === 'light') {
// a root covering this burn is already trusted: ~145-180k gas
await new Contract(p.light.releaseTo, wdAbi, signer).release(p.light.release)
} else {
// the root is submitted in the same transaction: ~2.6M gas
const s = p.hard.submitRoot
await new Contract(p.hard.releaseTo, wdAbi, signer)
.submitAndRelease(s.finality, s.bits, s.signature, s.policyKeys, s.pending, p.hard.release)
}A withdrawal to an EVM chain is paid out when someone sends its proof to
AlcorWithdrawals. Anyone may send it: every field is checked against a root
signed by Telos finalizers, and the tokens go only to the recipient the burn
named.
- Wait until
GET /v1/transfers/w:<id>showshasProof: true, about a minute after the burn. - Fetch
GET /v1/proofs/w:<id>. Its objects are in Solidity struct order and carry both contract addresses. Pass them through unchanged. - Send
releaseon thelighttrack orsubmitAndReleaseon thehardtrack. One transaction either way.
Deadline. The proof carries a deadline, about a week after the burn.
Past it release reverts with PastDeadline. Send the same body to expire (or
submitAndExpire) instead, and the tokens come back to their owner on Telos.
Daily limit. A release over the vault's remaining daily allowance reverts
with OutflowLimited and consumes nothing. The same proof works once the
allowance refills, any time before the deadline.
Fees. When the withdrawal set a fee, msg.sender receives it in the same
transaction. GET /v1/pending lists every withdrawal ready to settle right now,
with action saying release or expire.
Telos actions
# Collect a credited deposit
cleos -u https://mainnet.telos.net push action bridge.alcor claim '["alice", "USDC"]' -p alice| Action | Who may call | What it does |
|---|---|---|
bridge.alcor::claim(owner, sym_code) | The owner | Transfers a credited deposit to its owner. Table claims, scope = owner |
bridge.alcor::forward(domain, nonce) | Anyone | Delivers a deposit that carried a memo. The relayer does it; call it if you do not want to wait |
bridge.alcor::bounce(domain, nonce) | Anyone, an hour after arrival | Sends an undeliverable deposit back to its refundTo |
hop.alcor::recover(id) | Anyone | Sends a hop that came back from its destination on to its way-back address |
<gateway>::credit | Anyone, with a proof | Credits a deposit. The relayer does it. To do it yourself, send the actions of GET /v1/proofs/d:<domain>:<nonce> in one transaction |
Tables worth reading on bridge.alcor: routes (limits and backing per route),
claims (credited, not yet collected), wdraws (withdrawals and their status).
Contract events
| Contract | Event | Meaning |
|---|---|---|
AlcorVault | Deposit(token, from, amount, canonical, telosTo, nonce, refundTo, fillFee, filler, memo) | Deposit locked. Key d:<chainId>:<nonce> |
AlcorVault | Released(id, token, to, amount, feeTo, fee) | Withdrawal w:<id> paid out, raw units |
AlcorVault | Voided(id) | Withdrawal w:<id> expired, refunded on Telos |
AlcorWithdrawals | Released(id, token, to, amount, memo) | The same payout with the Telos memo, in Telos units |
AlcorWithdrawals | Expired(id) | expire accepted |
WAX bridge.alcor | action deplog(nonce, bridge, token_contract, token_symbol, from, raw, canonical, beneficiary, memo) | Deposit from WAX. Key d:1181148696416462999:<nonce> |
Telos bridge.alcor | action wdlog(id, domain, vault, token, recipient, amount, deadline, fee, memo) | Withdrawal opened. Key w:<id> |
Transfers and timing
curl https://telos.alcor.exchange/api/bridge/v1/transfers/d:56:1
curl "https://telos.alcor.exchange/api/bridge/v1/transfers?address=0x285202c8db763db06ab42e0705104cb7919ef95e&limit=20"GET /v1/transfers/<key> is one leg; GET /v1/transfers?account=&address=&direction=&status=&limit=&before=
lists legs, newest first (before is the src_time of the last row, for
paging). Transfer status follows a whole Ethereum ↔ WAX
journey instead. A leg moves through these statuses:
deposit seen ──▶ provable ──▶ credited
└─▶ parked ──▶ forwarded
└─▶ bounced
withdrawal burned ──▶ provable ──▶ released
└─▶ expired ──▶ refundedTypical times, measured on recent transfers. The live figures are in
Chains under timing:
| From → to | Time | Your transactions |
|---|---|---|
| Ethereum → Telos | about 17 min, Ethereum finality | Deposit |
| Ethereum → WAX | about 20 min | Deposit |
| BSC → Telos | about 30 s | Deposit |
| BSC → WAX | about 3 min | Deposit |
| WAX → Telos | about 50 s | Transfer |
| Telos → WAX | about 2 min | Transfer |
| Telos → Ethereum or BSC | about 1 min to the proof | Transfer, then release |
| WAX → Ethereum or BSC | about 2–3 min to the proof | Transfer, then release |
Poll every 10–15 seconds: a transfer changes state on the scale of minutes.