Performing Cross-VM Transfers
Native SUPRA can move between SupraMVM and SupraEVM. A transfer in either direction is called a crossing. This article describes how to submit one. To follow a crossing after you have submitted it, see Tracing Cross-VM Transfers.
There is one entry point per direction: an entry function on SupraMVM, and a precompile on the SupraEVM.
Before you start
A crossing does not complete in the block you submit it in. Value is escrowed or debited by one block and paid out by a later one, normally the next. The chain applies at most 128 crossings per direction per block, so under load a crossing can wait longer.
Units differ between the two sides. Native SUPRA has 8 decimals on SupraMVM, where the unit is the quant, and 18 decimals on SupraEVM, where the unit is the wei. One quant is 10^10 wei. SupraMVM settles whole quants only, so a value leaving SupraEVM must be a whole multiple of 10^10 wei.
The chain must have crossings enabled. They are gated on the SUPRA_EVM_SETTLEMENT feature
flag. With the flag off, both entry points refuse before taking any value.
At most 12,800 crossings may be outstanding in each direction. A crossing submitted against a
full backlog is refused rather than queued behind it, which is what bounds the wait. The refusal
comes before any value is taken, so it costs you nothing but the fee. in_flight.count in
GET /rpc/v4/supply/native is the number of crossings queued in both directions. The SupraMVM to
SupraEVM count is read with the 0x1::evm_settlement::outstanding_crossings() view function
and compared against max_outstanding_crossings(), where <RPC_HOST> is the RPC host with its
scheme, such as https://rpc-devnet.supra.com:
supra move tool view --function-id 0x1::evm_settlement::outstanding_crossings --rpc-url <RPC_HOST>
supra move tool view --function-id 0x1::evm_settlement::max_outstanding_crossings --rpc-url <RPC_HOST>Each view returns a one-element array holding the count as a string.
SupraMVM to SupraEVM
Call 0x1::evm_settlement::deposit_to_evm:
public entry fun deposit_to_evm(sender: &signer, amount: u64, recipient: vector<u8>)amount is in quants. recipient is the 20 bytes of the SupraEVM address to credit.
Only the zero address is refused. Value credited to an address that no one can spend from, a
precompile or system address for example, cannot be recovered.
The function withdraws amount from the sender’s SUPRA balance into the settlement escrow and
records the crossing. It is an ordinary user transaction: it costs ordinary gas, and it either
succeeds or aborts with the sender’s SUPRA untouched.
supra move tool run \
--function-id '0x1::evm_settlement::deposit_to_evm' \
--args u64:4200 hex:0x3131313131313131313131313131313131313131 \
--profile <PROFILE> --rpc-url <RPC_HOST> --accept-defaults<PROFILE> is a CLI profile whose account holds at least amount quants plus gas.
The transaction emits a SettlementRecorded event whose id field is the crossing’s settlement id.
Keep it: it is what the tracing endpoints are keyed by.
From Move code, call evm_settlement::deposit instead. It takes the recipient as an EvmAddress,
built with evm_settlement::new_evm_address, and returns the SettlementId directly.
When it aborts
| Abort | Cause |
|---|---|
EINVALID_EVM_ADDRESS, 0x10003 | recipient is not exactly 20 bytes. |
EZERO_EVM_ADDRESS, 0x10006 | recipient is the EVM zero address, which nobody can spend from. |
ENOT_INITIALIZED, 0x60002 | The chain has no settlement escrow, so it does not support crossings. |
ESETTLEMENT_DISABLED, 0x30008 | The chain has SUPRA_EVM_SETTLEMENT off. |
EZERO_AMOUNT, 0x10005 | amount is zero. |
EBACKLOG_FULL, 0x9000a | 12,800 crossings are already outstanding. Retry later. |
The checks run in the order listed. The abort code a client sees is the error category in the
upper bits and the reason number in the lower 16 bits. A deposit also aborts when the sender’s
SUPRA balance is below amount or the sender’s store is frozen; those aborts come from the
fungible-asset withdrawal and are not in the table.
An abort reverts the whole transaction, so your SUPRA stays with you in every one of these cases.
SupraEVM to SupraMVM
Make a plain CALL to the crossing precompile at:
0x0000000000000000000000000000000053555002The precompile has one entry point, and the call carries the crossing as its value:
interface ISupraNativeCrossing {
error UnknownSelector();
error MalformedInput();
error NotACall();
error StaticCall();
error NotWholeQuants();
error AmountTooLarge();
error BadRecipient();
error SettlementDisabled();
error BacklogFull();
event NativeCrossingToMove(bytes32 indexed settlementId, address indexed sender, bytes32 indexed recipient, uint256 amountQuants);
function crossToMove(bytes32 recipient) external payable returns (uint64 amountQuants);
}| Field | Value |
|---|---|
to | 0x0000000000000000000000000000000053555002 |
value | The amount to cross, in wei. Non-zero, and a whole multiple of 10^10. |
data | crossToMove(bytes32)’s selector, 0xe9892167, followed by the 32 bytes of the Move recipient address. 36 bytes in all, and nothing else. |
gas | Enough to cover 100,000 for the crossing on top of your own costs. |
The precompile debits the value, takes it out of SupraEVM’s circulation, and records the
crossing for SupraMVM to pay. It returns the amount it recorded, in quants: one ABI word
holding a uint64, which is the value you sent divided by 10^10. It charges a flat 100,000 gas.
That figure prices the SupraMVM work the crossing causes, which SupraMVM does not charge for,
and not the work the precompile itself does.
Any wallet or library that lets you set data on a value-bearing transaction can send one. From an
EOA, 121,576 gas covers the whole transaction in the worst case, so 150,000 is a safe limit. With
Foundry, where <ACCOUNT> is a keystore name and <MOVE_RECIPIENT> the recipient’s 32-byte Move
address as 64 hex digits with a 0x prefix:
cast send 0x0000000000000000000000000000000053555002 "crossToMove(bytes32)" <MOVE_RECIPIENT> \
--value 1ether --gas-limit 150000 --account <ACCOUNT> \
--rpc-url <RPC_HOST>/rpc/v1/eth/wallet_integrationFrom a contract, through the interface. The high-level call form needs solc 0.8.10 or newer:
earlier compilers emit an extcodesize check before an external call, and a precompile has no
code, so the check fails and the call reverts before it is made.
pragma solidity >=0.8.10;
// ISupraNativeCrossing is the interface above.
contract Crosser {
ISupraNativeCrossing constant NATIVE_CROSSING =
ISupraNativeCrossing(0x0000000000000000000000000000000053555002);
/// Sends `msg.value` to `moveRecipient` on SupraMVM, and returns the amount
/// recorded as crossing, in quants.
function cross(bytes32 moveRecipient) external payable returns (uint64) {
return NATIVE_CROSSING.crossToMove{value: msg.value}(moveRecipient);
}
}When a contract makes the call, the recorded sender, the refund target and the role=sender
lookup key are that contract, not the transaction’s originating account.
It must be a plain CALL. DELEGATECALL and CALLCODE are refused, because under them the value
the frame reports was never transferred to the precompile. STATICCALL is refused because a
crossing changes state.
When it reverts
A refused crossing reverts the frame, which returns the value you sent along with it. What the
refusal costs is what the transaction had already spent to reach the precompile: the 21,000
intrinsic gas, the gas for your calldata, and the CALL itself. None of the crossing’s 100,000 is
spent, because it is charged only for a crossing that is recorded.
Every refusal reverts with a custom error. The node returns the revert data as raw bytes, so a client decodes the reason only when its ABI declares the errors, as the interface above does. The errors split into two kinds: a permanent one says the call itself has to change, and a transient one says the same call succeeds on a later retry.
| Error | Kind | Cause |
|---|---|---|
UnknownSelector() | Permanent | The first four bytes of the call data are not 0xe9892167. Call data shorter than four bytes, including empty call data, gives MalformedInput(). |
MalformedInput() | Permanent | The call data is not the selector followed by exactly one 32-byte word. A recipient of the wrong length would send the value to the wrong address, so it is refused rather than padded or truncated. |
NotACall() | Permanent | Not entered as a plain CALL. See above. |
StaticCall() | Permanent | Called in a static frame. See above. |
NotWholeQuants() | Permanent | The value is zero, or carries a sub-quant remainder. It must be a non-zero multiple of 10^10 wei. |
AmountTooLarge() | Permanent | More quants than a Move fungible-asset amount can express. Cross less at a time. |
BadRecipient() | Permanent | The recipient is the Move zero address, which cannot hold a balance. |
SettlementDisabled() | Transient | The chain has SUPRA_EVM_SETTLEMENT off. |
BacklogFull() | Transient | 12,800 crossings are already outstanding. Retry later. |
Empty revert data means no ISupraNativeCrossing error was produced: either the call never
reached the precompile, or it ran out of gas. A CALL whose value exceeds the
caller’s balance is refused by the EVM as it builds the frame, which returns OutOfFunds to the
parent frame: the CALL returns 0 with empty return data, so a high-level Solidity call reverts
with empty revert data and a low-level .call returns false with empty return data. No
ISupraNativeCrossing error is carried, because the precompile did not run. An externally owned
account sending the crossing directly is refused earlier still, at admission, with
insufficient funds for gas * price + value from eth_sendRawTransaction.
Out of gas is reported as a precompile out-of-gas rather than a revert, and carries no error: an out-of-gas frame has no revert data. A precompile call that runs out of gas consumes all the gas forwarded to it, which for a direct call from an account is the transaction’s gas limit. In both this case and a refusal by revert, the value is returned to the caller.
A refusal by revert costs only the gas used. The call’s own properties are also decided before
the chain’s, which is what makes the two transient errors worth acting on: a call that could
never succeed is told which of its own properties to fix, and is never sent into a retry loop by
SettlementDisabled() or BacklogFull().
Reading the crossing back
The precompile records the crossing as a log at its own address, which your transaction’s receipt
carries. The interface above declares it as NativeCrossingToMove. settlementId is the crossing’s
settlement id, which the endpoints in Tracing Cross-VM
Transfers look the crossing up by.
amountQuants is in quants, not wei: one quant is 10^10 wei, so the wei value of the crossing is
amountQuants * 10 ** 10. It is the same number crossToMove returned.
After you submit
Neither direction is finished when your transaction returns.
From SupraMVM, take the settlement id out of the SettlementRecorded event. From SupraEVM,
take it out of the NativeCrossingToMove log, or look the crossing up by your address. Either way,
Tracing Cross-VM Transfers has the endpoints that follow a
crossing to completion, and explains what a refund is and why you must not resubmit one.