Skip to main content

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:

FieldDescription
tokenERC-20 address on Signet. Zero address for native USD.
amountAmount in the token’s smallest unit (e.g., wei).

Output:

FieldDescription
tokenERC-20 address on the destination chain.
amountAmount in the token’s smallest unit.
recipientDelivery address on the destination chain.
chainIdDestination 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

ContractChainAddress
Permit2Signet0x000000000022D473030F116dDEE9F6B43aC78BA3
RollupOrdersSignet0x000000000000007369676e65742d6f7264657273

All contract addresses on this page are for Parmigiana testnet.

Order types

The SDK’s type definitions for order inputs and outputs:

typescript
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:

typescript
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
typescript
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:

typescript
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
typescript
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():

typescript
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:

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

typescript
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:

typescript
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:

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

typescript
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:

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

typescript
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:

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

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

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

bash
cargo add signet-orders

OrderSender is generic over any alloy Signer and any OrderSubmitter backend. A ready-made OrderSubmitter implementation is provided for TxCache.

rust
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:

rust
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:

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

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

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

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

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

solidity
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:

bash
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_KEY

The 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. 3151908 is 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:

bash
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:

bash
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:

bash
cast balance $YOUR_ADDRESS --rpc-url $HOST_RPC

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

ESC

Start typing to search documentation...

Navigate Select ⌘K Open