Skip to Content
Supra Layer 1Performing Cross-VM Transfers

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

AbortCause
EINVALID_EVM_ADDRESS, 0x10003recipient is not exactly 20 bytes.
EZERO_EVM_ADDRESS, 0x10006recipient is the EVM zero address, which nobody can spend from.
ENOT_INITIALIZED, 0x60002The chain has no settlement escrow, so it does not support crossings.
ESETTLEMENT_DISABLED, 0x30008The chain has SUPRA_EVM_SETTLEMENT off.
EZERO_AMOUNT, 0x10005amount is zero.
EBACKLOG_FULL, 0x9000a12,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:

0x0000000000000000000000000000000053555002

The 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); }
FieldValue
to0x0000000000000000000000000000000053555002
valueThe amount to cross, in wei. Non-zero, and a whole multiple of 10^10.
datacrossToMove(bytes32)’s selector, 0xe9892167, followed by the 32 bytes of the Move recipient address. 36 bytes in all, and nothing else.
gasEnough 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_integration

From 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.

ErrorKindCause
UnknownSelector()PermanentThe first four bytes of the call data are not 0xe9892167. Call data shorter than four bytes, including empty call data, gives MalformedInput().
MalformedInput()PermanentThe 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()PermanentNot entered as a plain CALL. See above.
StaticCall()PermanentCalled in a static frame. See above.
NotWholeQuants()PermanentThe value is zero, or carries a sub-quant remainder. It must be a non-zero multiple of 10^10 wei.
AmountTooLarge()PermanentMore quants than a Move fungible-asset amount can express. Cross less at a time.
BadRecipient()PermanentThe recipient is the Move zero address, which cannot hold a balance.
SettlementDisabled()TransientThe chain has SUPRA_EVM_SETTLEMENT off.
BacklogFull()Transient12,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.

Last updated on