Exit Signet
Move assets from Signet back to Ethereum
Create and submit orders to move assets from Signet to Ethereum. For background on the order lifecycle and atomic settlement, see How orders work.
Order data model
Every order has inputs (what the user gives up on Signet) and outputs (what they receive on the destination chain).
Input:
| Field | Description |
|---|---|
token | ERC-20 address on Signet. Zero address for native USD. |
amount | Amount in the token’s smallest unit (e.g., wei). |
Output:
| Field | Description |
|---|---|
token | ERC-20 address on the destination chain. |
amount | Amount in the token’s smallest unit. |
recipient | Delivery address on the destination chain. |
chainId | Destination chain ID. |
An order can have multiple inputs and multiple outputs across different chains and recipients. The data model is ERC-7683 compliant.
On-chain vs gasless
On-chain orders call RollupOrders.initiate directly. The user sends a transaction and pays gas. Fillers discover it via Order events.
Gasless orders use Permit2. The user signs an EIP-712 message (no transaction, no gas) and submits it to the transaction cache, where fillers pick it up. Requires a one-time Permit2 token approval.
Pricing
The spread between input and output is the filler’s incentive. A user offering 1 WETH on Signet for 0.995 WETH on Ethereum gives the filler 50 basis points (0.5%). If your order isn’t getting filled, widen the spread. If it fills instantly every time, you may be offering more than necessary.
FAQ
What happens if nobody fills? The order expires at the deadline. Tokens stay on Signet. Nothing is lost.
Can an order partially fill? No. The entire output must be delivered, or nothing happens.
Can my user cancel? On-chain orders created via initiate cannot be cancelled – the deadline is the only expiry mechanism. Gasless Permit2 orders can be cancelled by consuming the Permit2 nonce.
How long should the deadline be? Shorter deadlines reduce the time funds are committed but give fillers less time to act. Start with 1 minute for testing.
This page assumes you’ve set up your clients.
Contracts
| Contract | Chain | Address |
|---|---|---|
| Permit2 | Signet | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| RollupOrders | Signet | 0x000000000000007369676e65742d6f7264657273 |
All contract addresses on this page are for Parmigiana testnet.
Order types
The SDK’s type definitions for order inputs and outputs:
type Input = {
token: `0x${string}`;
amount: bigint;
};
type Output = {
token: `0x${string}`;
amount: bigint;
recipient: `0x${string}`;
chainId: number;
};On-chain exit (costs gas)
Call initiate on RollupOrders to create an exit order directly. The inputs array is what the user gives up on Signet; the outputs array is what they want on Ethereum.
Here’s an exit that converts 1 USD on Signet to WETH on Ethereum:
import { rollupOrdersAbi } from '@signet-sh/sdk/abi';
import { PARMIGIANA } from '@signet-sh/sdk/constants';
import { getTokenAddress } from '@signet-sh/sdk/tokens';
import { parseEther } from 'viem';
const hostWeth = getTokenAddress('WETH', PARMIGIANA.hostChainId, PARMIGIANA);
const inputAmount = parseEther('1');
const outputAmount = (inputAmount * 995n) / 1000n; // 50bps to fillers
const hash = await signetWalletClient.writeContract({
address: PARMIGIANA.rollupOrders,
abi: rollupOrdersAbi,
functionName: 'initiate',
args: [
BigInt(Math.floor(Date.now() / 1000) + 60), // 1 minute deadline
[{ token: '0x0000000000000000000000000000000000000000', amount: inputAmount }],
[{ token: hostWeth, amount: outputAmount, recipient: userAddress, chainId: Number(PARMIGIANA.hostChainId) }],
],
value: inputAmount,
});Without the SDK
import { parseEther } from 'viem';
const initiateAbi = [
{
name: 'initiate',
type: 'function',
stateMutability: 'payable',
inputs: [
{ name: 'deadline', type: 'uint256' },
{
name: 'inputs',
type: 'tuple[]',
components: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
},
{
name: 'outputs',
type: 'tuple[]',
components: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'chainId', type: 'uint32' },
],
},
],
outputs: [],
},
] as const;
const inputAmount = parseEther('1');
const outputAmount = (inputAmount * 995n) / 1000n;
const hash = await signetWalletClient.writeContract({
address: '0x000000000000007369676e65742d6f7264657273',
abi: initiateAbi,
functionName: 'initiate',
args: [
BigInt(Math.floor(Date.now() / 1000) + 60),
[{ token: '0x0000000000000000000000000000000000000000' as `0x${string}`, amount: inputAmount }],
[{ token: '0xD1278f17e86071f1E658B656084c65b7FD3c90eF' as `0x${string}`, amount: outputAmount, recipient: userAddress, chainId: 3151908 }],
],
value: inputAmount,
});On-chain orders created via initiate cannot be cancelled. The deadline is the only expiry mechanism. For pricing guidance, see the pricing section in the shared intro above.
Gasless exit (Permit2)
The gasless flow uses Permit2 for token approvals. The user signs a message (not a transaction) and pays zero gas.
Step 1: Approve Permit2
One-time per token. ensurePermit2Approval checks the current allowance and only sends a transaction if needed:
import { ensurePermit2Approval } from '@signet-sh/sdk/permit2';
const { approved, txHash } = await ensurePermit2Approval(signetWalletClient, signetPublicClient, {
token: weth,
owner: userAddress,
amount: parseEther('1'),
});It handles USDT-style tokens that require resetting allowance to zero before setting a new value.
Without the SDK
import { erc20Abi, maxUint256 } from 'viem';
await signetWalletClient.writeContract({
address: inputTokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: ['0x000000000022D473030F116dDEE9F6B43aC78BA3', maxUint256],
});Step 2: Build and sign the order
UnsignedOrder is a builder. Chain the inputs, outputs, deadline, nonce, and chain config, then call .sign():
import { UnsignedOrder, randomNonce } from '@signet-sh/sdk/signing';
import { PARMIGIANA } from '@signet-sh/sdk/constants';
const signed = await UnsignedOrder.new()
.withInput(weth, parseEther('1'))
.withOutput(usdc, 2985_000000n, userAddress, Number(PARMIGIANA.hostChainId))
.withDeadline(BigInt(Math.floor(Date.now() / 1000) + 60))
.withNonce(randomNonce())
.withChain({ chainId: PARMIGIANA.rollupChainId, orderContract: PARMIGIANA.rollupOrders })
.sign(signetWalletClient);.sign() constructs EIP-712 typed data (Permit2’s PermitBatchWitnessTransferFrom with the order outputs as the witness) and calls signetWalletClient.signTypedData.
Step 3: Check feasibility
Before submitting, verify that the signer has sufficient balance and Permit2 allowance. checkOrderFeasibility reads on-chain state and returns any issues:
import { checkOrderFeasibility } from '@signet-sh/sdk/signing';
const result = await checkOrderFeasibility(signetPublicClient, signed);
if (!result.feasible) {
// result.issues contains the specific problems:
// - 'insufficient_balance' (token, required, available)
// - 'insufficient_allowance' (token, required, available)
// - 'nonce_used' (Permit2 nonce already consumed)
console.error(result.issues);
return;
}This catches balance, allowance, and nonce failures before submission. Surface them inline rather than submitting an order that will never fill.
Step 4: Submit to the tx-cache
The transaction cache is Signet’s submission service. Your application posts a signed order to its /orders endpoint; fillers poll the same service to discover and evaluate new orders.
import { createTxCacheClient } from '@signet-sh/sdk/client';
import { PARMIGIANA } from '@signet-sh/sdk/constants';
const txCache = createTxCacheClient(PARMIGIANA.txCacheUrl);
await txCache.submitOrder(signed);submitOrder serializes the signed order and POSTs it to {txCacheUrl}/orders. The tx-cache validates the signature and Permit2 structure before making the order visible to fillers.
Reading order status
Query Order events from RollupOrders using the SDK’s ABI:
import { rollupOrdersAbi } from '@signet-sh/sdk/abi';
import { PARMIGIANA } from '@signet-sh/sdk/constants';
const logs = await signetPublicClient.getLogs({
address: PARMIGIANA.rollupOrders,
abi: rollupOrdersAbi,
eventName: 'Order',
fromBlock: startBlock,
toBlock: 'latest',
});Check fills on the Ethereum side:
import { hostOrdersAbi } from '@signet-sh/sdk/abi';
import { PARMIGIANA } from '@signet-sh/sdk/constants';
const fills = await hostPublicClient.getLogs({
address: PARMIGIANA.hostOrders,
abi: hostOrdersAbi,
eventName: 'Filled',
fromBlock: startBlock,
toBlock: 'latest',
});Without the SDK
These are simplified event definitions. For production use, import the full ABI from @signet-sh/sdk/abi or download from the ABI files.
const logs = await signetPublicClient.getLogs({
address: '0x000000000000007369676e65742d6f7264657273',
event: {
type: 'event',
name: 'Order',
inputs: [
{ name: 'deadline', type: 'uint256', indexed: false },
{ name: 'inputs', type: 'tuple[]', indexed: false },
{ name: 'outputs', type: 'tuple[]', indexed: false },
],
},
fromBlock: startBlock,
toBlock: 'latest',
});
const fills = await hostPublicClient.getLogs({
address: '0x96f44ddc3Bc8892371305531F1a6d8ca2331fE6C',
event: {
type: 'event',
name: 'Filled',
inputs: [
{ name: 'outputs', type: 'tuple[]', indexed: false },
],
},
fromBlock: startBlock,
toBlock: 'latest',
});Detecting unfilled orders
Orders expire at their deadline (see the FAQ above). To programmatically detect whether a gasless order was filled, check whether its Permit2 nonce has been consumed:
import { PARMIGIANA, PERMIT2_ADDRESS } from '@signet-sh/sdk/constants';
const orderDeadline = 1710000000n; // from the Order event
const orderNonce = 42n; // the nonce used when creating the order
const block = await signetPublicClient.getBlock();
// Check if the Permit2 nonce has been consumed
const wordPos = orderNonce / 256n;
const bitPos = orderNonce % 256n;
const bitmap = await signetPublicClient.readContract({
address: PERMIT2_ADDRESS,
abi: [{ name: 'nonceBitmap', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'wordPos', type: 'uint256' }], outputs: [{ name: '', type: 'uint256' }] }],
functionName: 'nonceBitmap',
args: [userAddress, wordPos],
});
const nonceConsumed = (bitmap & (1n << bitPos)) !== 0n;
if (block.timestamp > orderDeadline && !nonceConsumed) {
// Order expired unfilled, prompt user to retry with a wider spread
} else if (nonceConsumed) {
// Order was filled (possibly batched with other orders)
}For error handling and common issues, see Troubleshooting.
Cross-chain swap in 15 lines
Swap 1 WETH on Signet for ~2,985 USDC on Ethereum, gasless. In production, add the feasibility check before submission.
import { UnsignedOrder, randomNonce } from '@signet-sh/sdk/signing';
import { PARMIGIANA } from '@signet-sh/sdk/constants';
import { getTokenAddress } from '@signet-sh/sdk/tokens';
import { ensurePermit2Approval } from '@signet-sh/sdk/permit2';
import { createTxCacheClient } from '@signet-sh/sdk/client';
import { parseEther } from 'viem';
const weth = getTokenAddress('WETH', PARMIGIANA.rollupChainId, PARMIGIANA);
const usdc = getTokenAddress('USDC', PARMIGIANA.hostChainId, PARMIGIANA);
// 1. One-time: let Permit2 spend your WETH
await ensurePermit2Approval(signetWalletClient, signetPublicClient, {
token: weth, owner: userAddress, amount: parseEther('1'),
});
// 2. Build and sign the order
const signed = await UnsignedOrder.new()
.withInput(weth, parseEther('1'))
.withOutput(usdc, 2985_000000n, userAddress, Number(PARMIGIANA.hostChainId))
.withDeadline(BigInt(Math.floor(Date.now() / 1000) + 60))
.withNonce(randomNonce())
.withChain({ chainId: PARMIGIANA.rollupChainId, orderContract: PARMIGIANA.rollupOrders })
.sign(signetWalletClient);
// 3. Submit to the tx-cache, fillers pick it up from here
const txCache = createTxCacheClient(PARMIGIANA.txCacheUrl);
await txCache.submitOrder(signed);Next steps
For Ethereum-to-Ethereum swaps using Signet invisibly, see Enter Signet for the Passage deposit flow.
For advanced use cases, exits can also be constructed as part of a Bundle, giving you full control over transaction ordering and atomic multi-step operations.
For Permit2 data structures, see the Uniswap Permit2 docs.
Create and submit off-chain Orders using Rust and the Signet SDK.
This page assumes you’ve completed the getting started setup. Ensure your account has approved Permit2 to spend your input tokens.
Creating an Order
The signet-types crate provides a simple order builder via the UnsignedOrder struct, which can be used to build orders. Create a simple order that swaps 1 WETH on Signet for 1 WETH on Ethereum:
use signet_types::signing::order::{UnsignedOrder};
use signet_constants::parmigiana as constants;
let order = UnsignedOrder::default()
.with_input(
constants::RU_WETH,
U256::from(1_000_000_000_000_000_000u128), // 1 WETH
).with_output(
constants::HOST_WETH,
U256::from(1_000_000_000_000_000_000u128),
your_address,
3151908, // Parmigiana host chain
);The UnsignedOrder struct also provides methods to sign orders, using any
alloy signer. The signer requires that you provide the constants object, so
that the permit2 signer can correctly derive the domain separator.
use signet_types::signing::order::{UnsignedOrder};
use signet_constants::parmigiana as constants;
let signed = UnsignedOrder::default()
.with_input(token_address, amount)
.with_output(token_address, amount, recipient, chain_id)
.with_chain(constants::system_constants())
.with_nonce(permit2_nonce)
.sign(&signer).await?;Submitting an Order
Once signed, the order can be submitted to the Signet network via the Signet tx cache. The tx cache makes the order available to fillers, who will include it in execution bundles.
use signet_tx_cache::TxCache;
let tx_cache = TxCache::parmigiana();
tx_cache.forward_order(signed_order).await?;Using OrderSender
For long-running services or bots, the
signet-orders crate
provides OrderSender
, a reusable struct that wraps signing and submission into a single interface.
cargo add signet-ordersOrderSender is generic over any alloy
Signer and any
OrderSubmitter
backend. A ready-made OrderSubmitter implementation is provided for TxCache.
use signet_constants::parmigiana;
use signet_orders::OrderSender;
use signet_tx_cache::TxCache;
// Any alloy Signer works: LocalSigner, AwsSigner, LedgerSigner, etc.
let signer = /* your signer */;
let order_sender = OrderSender::new(
signer,
TxCache::parmigiana(),
parmigiana::system_constants(),
);
// Sign and submit in one call
let signed = order_sender.sign_and_send_order(order).await?;You can also sign and send separately for more control:
let signed = order_sender
.sign_unsigned_order(
UnsignedOrder::default()
.with_input(token_address, amount)
.with_output(token_address, amount, recipient, chain_id),
)
.await?;
// Submit when ready
order_sender.send_order(signed).await?;If you need to submit orders somewhere other than the tx cache, implement the
OrderSubmitter trait:
use signet_orders::OrderSubmitter;
use signet_types::SignedOrder;
struct MySubmitter;
impl OrderSubmitter for MySubmitter {
type Error = MyError;
async fn submit_order(&self, order: SignedOrder) -> Result<(), Self::Error> {
// Forward the order to your backend
todo!()
}
}Example contracts that create and fill Signet Orders. View the full set at signet-sol.
This page assumes you’ve completed the getting started setup.
Example contracts
SignetL2.sol - System configuration
Base contract that auto-resolves Signet system addresses by chain ID.
Inherit SignetL2 for automatic access to rollupOrders, permit2, and other system addresses.
import {SignetL2} from "./Signet.sol";
contract YourContract is SignetL2 {
function createOrder() external {
// rollupOrders, permit2, etc. are available
}
}Source: Signet.sol
Flash.sol - Flash loans
Borrow an asset for the duration of a single function call using an Order where the output repays the input.
flashBorrow(wethAddress, amount, arbitrageCalldata);The flash modifier emits an Order with both an output (asset received before execution) and an input (asset returned after). The filler provides the liquidity on either side.
Source: Flash.sol
GetOut.sol - Quick exit
Exit Signet by offering fillers a 50 basis point fee.
getOut(tokenAddress, amount, recipient);Locks tokens on Signet and creates an Order offering 99.5% on Ethereum. Fillers fill for the 0.5% spread.
Source: GetOut.sol
PayMe.sol - Payment gating
Gate execution behind a payment Order with no inputs. Any third party can fill.
function executeWithPayment() external {
payMe(usdcAddress, 10e6); // Require 10 USDC payment
// Your logic here -- only executes if payment Order is filled
}The contract creates an Order with only outputs (payment required). Unlike msg.value checks, the payer can be anyone.
Source: PayMe.sol
PayYou.sol - Execution bounties
Offer MEV by emitting an Order with inputs and no outputs, creating a bounty for calling your contract.
function executeAndPay() external {
performArbitrage();
payYou(wethAddress, 0.01 ether); // 0.01 ETH bounty
}Searchers compete to call the function and capture the bounty. Useful for liquidations, automated rebalancing, and scheduled maintenance.
Source: PayYou.sol
Composing patterns
These patterns compose: a single transaction can combine an automated exit with an execution bounty, or a flash loan with a payment gate. All of these are conditional transactions, and any can be triggered from Ethereum via the Transactor.
This page assumes you’ve completed the getting started setup.
The Terminal variant covers the on-chain exit flow only (initiate). The gasless Permit2 flow requires EIP-712 typed data signing, which cast doesn’t support. For gasless orders, use the TypeScript or Rust SDK.
Create an exit order
Call initiate on RollupOrders to create an exit order on Signet. This example exits 1 USD (native asset, sent as value) for ~0.995 WETH on Ethereum:
cast send 0x000000000000007369676e65742d6f7264657273 \
"initiate(uint256,(address,uint256)[],(address,uint256,address,uint32)[])" \
$(( $(date +%s) + 60 )) \
"[(0x0000000000000000000000000000000000000000,1000000000000000000)]" \
"[(0xD1278f17e86071f1E658B656084c65b7FD3c90eF,995000000000000000,$YOUR_ADDRESS,3151908)]" \
--value 1ether \
--rpc-url $SIGNET_RPC \
--private-key $PRIVATE_KEYThe arguments:
- deadline: Unix timestamp (here, 60 seconds from now)
- inputs: array of
(token, amount)tuples.address(0)means native USD, sent via--value - outputs: array of
(token, amount, recipient, chainId)tuples.3151908is Parmigiana’s host chain ID
The spread between input (1.0) and output (0.995) is the filler’s incentive.
All contract addresses on this page are for Parmigiana testnet.
Read order events
Query Order events from RollupOrders:
cast logs \
--from-block $START_BLOCK \
--rpc-url $SIGNET_RPC \
--address 0x000000000000007369676e65742d6f7264657273 \
"Order(uint256,(address,uint256)[],(address,uint256,address,uint32)[])"Check if an order was filled
Poll for Filled events on HostOrders on Ethereum:
cast logs \
--from-block $ORDER_BLOCK \
--rpc-url $HOST_RPC \
--address 0x96f44ddc3Bc8892371305531F1a6d8ca2331fE6C \
"Filled((address,uint256,address,uint32)[])"Or check the recipient’s balance on Ethereum directly, since fills settle in the same block as the order:
cast balance $YOUR_ADDRESS --rpc-url $HOST_RPCWhat if nobody fills?
The order expires at the deadline. Tokens stay on Signet, nothing is lost. Create a new order with a wider spread or longer deadline.