Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Gasless Trading & Relayers

Eight actions on a pair can be signed instead of sent: the user signs an EIP-712 request in their wallet, and a relayer submits it from its own account. The relayer pays the gas and the Pyth fee in ETH; the pair credits it the relayerFee the user signed, in USDC, on its claimable balance. With a USDC permit riding along, a user needs no ETH and no approve transaction at all.

RelayableNot relayable
depositCollateral, withdrawCollateral, createOrder, cancelOrders, amendOrders, depositToInsurance, requestInsuranceWithdrawal, executeInsuranceWithdrawalclaimFees, executeTriggeredOrders, and everything outside the pair (approvals, termination, listings).

Gasless calls also solve the 3-second price window for browser users: the price update is not part of what the user signs, so the relayer attaches a fresh one when it submits, however long the user took to approve.

This page covers both sides: signing on the frontend, and a complete relayer server. The protocol’s rules are in Gasless Transactions. The samples assume bz connected as in Connecting, user the wallet’s address and eth a market.

How It Works

  1. The frontend calls bz.relay.signRelayed(input, opts). It checks the action against the chain (as the direct builders do), signs a USDC permit first if asked, then has the wallet sign the request, and returns a RelayedRequest.
  2. It posts relay.serializeRelayed(req) (JSON) to the relayer, at once: the request is valid for at most 10 seconds.
  3. The relayer parses it (relay.parseRelayed), checks it (bz.relay.verifyRelayed), builds the call with a fresh price update and fresh book hints (bz.relay.encodeRelayed), and sends it from its own key.
  4. The pair recovers the signer from the signature, acts as them, pulls the fee from their wallet (or nets it from a payout) and credits it to whoever submitted the transaction.
  5. The relayer collects its fees per pair with claimFees.

Signing a Request

/** POST a signed request to the relayer at once: it is valid for at most 10 seconds. */
async function submit(req: relay.RelayedRequest): Promise<Hex> {
  const res = await fetch(RELAYER_URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: relay.serializeRelayed(req),
  });
  const body = (await res.json()) as { hash?: Hex; error?: string };
  if (!res.ok || !body.hash) throw new Error(body.error ?? `the relayer answered ${res.status}`);
  return body.hash;
}
try {
  const block = await bz.ctx.chain.blockNumber();
  const req = await bz.relay.signRelayed(
    {
      action: "createOrder",
      order: {
        orderType: OrderType.Limit,
        isLong: true,
        size: parseUnits18("0.5"),
        limitPrice: parseUnits18("2950"),
        expirationBlock: block + orders.blocksIn(24 * 60 * 60),
      },
    },
    {
      wallet: window.ethereum,
      signer: user,
      pair: eth.address,
      relayerFee: parseUnits18("0.05"), // what this relayer charges, in USDC, at most $1
      permit: true, // sign a USDC permit for the fee too, unless the allowance already covers it
    },
  );
  const hash = await submit(req);
  await bz.waitForReceipt(hash);
} catch (e) {
  // A declined signature or a clock problem is a SigningError, whose message is the sentence to show.
  show(e instanceof signing.SigningError ? e.message : errorMessage(e));
}

bz.relay.signRelayed(input, opts) takes what to do as input, one of:

inputSigns
{ action: "createOrder", order }An OrderInput (Placing Orders), checked against the chain as prepareOrder does, with the connection’s integrator.
{ action: "amendOrders", amendments }AmendInput[] (Managing Orders), checked and given hints as prepareAmends does.
{ action: "depositCollateral", amount }, { action: "withdrawCollateral", amount }, { action: "depositToInsurance", amount }An amount in BAZAAR precision. Deposits are floored to micro-USDC first.
{ action: "requestInsuranceWithdrawal", shareAmount }, { action: "executeInsuranceWithdrawal" }See Insurance.
{ action: "cancelOrders", orderIds }1 to 200 distinct order ids.
{ action: "createOrder", spec }, { action: "amendOrders", amends }An already built OrderSpec, or the flat amendment words, signed as given.
OptionDefaultMeaning
walletrequiredThe user’s EIP-1193 provider.
signerrequiredThe user’s address. The wallet must sign as it.
pairrequiredThe market’s pair. It is the domain the user signs under and the permit’s spender, so it must be one of the deployment’s pairs (InputError on pair otherwise, before anything is asked).
relayerFee0nWhat the relayer charges, BAZAAR precision, at most $1. Floored to micro-USDC before signing, so the signed fee is exactly what is charged.
deadlineWindow10Seconds the signature stays valid, at most the deployment’s window (10).
noncereadThe nonce to sign. Read from the pair when omitted; pass it to sign several requests in a row.
permitnonetrue, or { value?, deadline?, always? }: also sign a USDC permit for what the call takes from the wallet. See Permits on the Gasless Path.

What it does, in order:

  1. Floors the fee, reads the deployment’s fee cap and deadline window from the lens (eip712Limits, once per connection), and confirms the pair.
  2. Resolves and checks the action: an order or amendments against the chain, a deposit’s floor, a withdrawal on a terminated market against what it pays (checkSignedFields, below).
  3. Signs the permit, when asked, as its own wallet prompt. This happens before the request’s deadline is stamped, so the time the user spends on the permit prompt does not count against the 10 seconds.
  4. Reads the nonce and stamps the deadline (chainDeadline).
  5. Asks the wallet to sign the typed data with eth_signTypedData_v4, normalizes the signature and recovers it locally: a signature that does not recover to signer is refused (SigningError).

It returns a RelayedRequest: the signed fields (action and the action’s own arguments), pair, chainId, signer, nonce, deadline (unix seconds), relayerFee (floored), signature and permitData ("0x" without a permit).

const cancel = await bz.relay.signRelayed({ action: "cancelOrders", orderIds: [42n] }, { wallet: window.ethereum, signer: user, pair: eth.address });

const amend = await bz.relay.signRelayed(
  { action: "amendOrders", amendments: [{ orderId: 42n, limitPrice: parseUnits18("2960"), size: parseUnits18("0.5") }] },
  { wallet: window.ethereum, signer: user, pair: eth.address, relayerFee: parseUnits18("0.02"), permit: true },
);

const withdraw = await bz.relay.signRelayed(
  { action: "withdrawCollateral", amount: parseUnits18("50") }, // the fee comes out of the payout: no permit
  { wallet: window.ethereum, signer: user, pair: eth.address, relayerFee: parseUnits18("0.05") },
);

The Deadline Window

The pair accepts a signature only before its deadline and only if the deadline is at most 10 seconds past the landing block’s timestamp (MAX_DEADLINE_WINDOW), so a signed request cannot be stored and used later. That makes the timing tight:

  • The deadline is counted from a checked clock: the lower of the latest block’s timestamp and the device’s clock. If the two differ by more than 2 minutes (MAX_CLOCK_SKEW), signRelayed throws ClockSkewError and nothing is signed: an RPC reporting a block far in the future must not stretch a deadline. A device clock so far behind the chain that the deadline would already have passed by chain time is refused the same way. Local development chains (31337, 1337) use their own time unchecked.
  • The user has the window to approve the prompt. The deadline is stamped just before the wallet is asked. If the user takes longer than the window, the request arrives expired: the relayer’s check reports it (deadline: expired 4 s ago) and the pair would refuse it (MetaTx__ExpiredDeadline). Ask the user to sign again; do not retry the same request.
  • A device clock behind the chain shortens the window: with the clock 4 seconds behind, a 10-second window leaves 6 seconds by chain time.
  • Post at once, and the relayer must verify, encode and send at once.

bz.signing.chainDeadline(windowSeconds?) returns such a deadline for your own signing code, and signing.checkClockSkew(chainNow, localNow?) the check alone.

Nonces

Each signer has one nonce per pair (metaTxNonces), shared by every action, strictly sequential: a request lands only at the signer’s current nonce, and each landed request uses one.

  • signRelayed reads it (bz.signing.readMetaTxNonce(pair, user)) unless you pass nonce.
  • To sign several requests before any lands, pass consecutive nonces and submit them in nonce order; the relayer must land each before the next is valid.
  • A request whose nonce was used (another request landed first) fails with MetaTx__InvalidNonce; sign it again.
// Two requests in a row: sign both against consecutive nonces, submit in nonce order.
const next = await bz.signing.readMetaTxNonce(eth.address, user);
const first = await bz.relay.signRelayed({ action: "cancelOrders", orderIds: [7n] }, { wallet: window.ethereum, signer: user, pair: eth.address, nonce: next });
const second = await bz.relay.signRelayed({ action: "cancelOrders", orderIds: [8n] }, { wallet: window.ethereum, signer: user, pair: eth.address, nonce: next + 1n });
await submit(first);
await submit(second);

Fees

The fee is the relayer’s price, agreed off chain: the frontend signs whatever the relayer charges, and the relayer refuses requests below its price. The pair caps it at $1 per action (MAX_RELAYER_FEE, checked against the lens’s maxRelayerFee) and charges it at its USDC floor (signing.floorRelayerFee).

Where it comes from depends on the action:

ActionThe signer’s wallet pays (relay.relayedPull)
depositCollateral, depositToInsuranceThe deposit plus the fee.
createOrderThe fee plus the trigger order’s keeper bounty.
cancelOrders, amendOrders, requestInsuranceWithdrawalThe fee.
executeInsuranceWithdrawalThe fee on the first, locking call; nothing on the paying call, whose fee comes out of the payout.
withdrawCollateralNothing: the fee comes out of the payout. On a terminated market whose payout ratio leaves 0, the fee comes from the wallet.

checkSignedFields(fields, relayerFee, terminalPayout?) enforces the bounds that need no read, and signRelayed and verifyRelayed both run it: deposits of at least 5 USDC that exceed the fee, a withdrawal larger than the fee (or, on a terminated market, a payout larger than the fee), a non-zero share amount, 1 to 200 distinct cancel ids, 1 to 32 well-formed amendments, and an order spec the order rules accept. A withdrawal on a terminated market that would pay nothing is refused.

Permits on the Gasless Path

permit: true signs a USDC permit for exactly what the call takes from the wallet (relayedPull, rounded up to USDC units), so a user who has never approved anything can deposit, trade, amend and cancel with signatures alone:

// A first deposit with no ETH and no approval: the permit covers the deposit plus the fee.
const deposit = await bz.relay.signRelayed(
  { action: "depositCollateral", amount: parseUnits18("100") },
  { wallet: window.ethereum, signer: user, pair: eth.address, relayerFee: parseUnits18("0.05"), permit: true },
);
await submit(deposit);
permitMeaning
trueSign a permit for the pull, unless the pair’s current allowance already covers it.
{ value }The permit’s value in USDC units, instead of the pull.
{ deadline }The permit’s expiry, unix seconds (default 30 minutes).
{ always: true }Sign one even when the allowance covers the pull.

The allowance check matters because a permit sets the allowance to its value: one signed anyway would replace a larger approval with this call’s amount. A withdrawal takes no permit (InputError on permit). See USDC Permits for the rest of the rules, including signing one permit at a time.

Smart-Contract Wallets Cannot Sign

The pair verifies with plain ECDSA (ECDSA.recover) and acts for whatever address the signature recovers to. A smart-contract wallet (a Safe, an ERC-4337 account) signs through ERC-1271, whose signature recovers to some other address or to none. signRelayed catches this: it recovers the signature locally and throws SigningError (“this wallet does not sign as a plain account. Gasless calls need an account’s own signature; send the transaction directly instead”). Smart accounts send their calls directly, and can batch them: see Smart Accounts & Batching.

The Wire Format

relay.serializeRelayed(req) is JSON.stringify with every bigint as a decimal string:

{
  "action": "cancelOrders",
  "orderIds": ["42"],
  "pair": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
  "chainId": 42161,
  "signer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  "nonce": "3",
  "deadline": 1762612345,
  "relayerFee": "50000000000000000",
  "signature": "0x1b2c...",
  "permitData": "0x"
}

A createOrder request carries spec (the OrderSpec, with its numbers as strings) instead of orderIds; deposits and withdrawals carry amount, amendOrders amends, and requestInsuranceWithdrawal shareAmount.

relay.parseRelayed(body) takes the JSON string (or an already parsed object) and returns a RelayedRequest, checking every field’s type and shape (InputError naming the field otherwise: "nonce: expected a non-negative integer"). It canonicalizes the signature into the one form the pair accepts (65 bytes, v 27 or 28): a v of 0 or 1 and a 64-byte EIP-2098 signature are converted, and a high-s signature is refused. It does not verify the signature; that is verifyRelayed.

Running a Relayer

A relayer is a small service with a funded key: ETH for gas and Pyth fees on the deployment’s chain. It earns USDC per request. The complete server below uses Node’s own node:http; any HTTP framework works the same way.

Setup

const RPC_URL = process.env.RPC_URL ?? "http://127.0.0.1:8545";
const FACTORY = (process.env.BAZAAR_FACTORY ?? "0x0000000000000000000000000000000000000000") as Address;
const PORT = Number(process.env.PORT ?? 8787);
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN ?? "https://app.example.com";

/** The least this relayer accepts per request, and the most Pyth fee it will pay for one. */
const MIN_FEE = parseUnits18("0.02");
const MAX_PYTH_FEE = parseEther("0.0005");

const account = privateKeyToAccount(process.env.RELAYER_PRIVATE_KEY as Hex, { nonceManager });
const wallet = createWalletClient({ account, chain: arbitrum, transport: http(RPC_URL) });

const bz = await Bazaar.connect({
  transport: RPC_URL,
  factory: FACTORY,
  chainId: arbitrum.id,
  prices: { token: process.env.PYTH_PRO_API_KEY }, // the relayer attaches fresh prices; a server may hold the key
});

// Refuse to start if what users sign is not what this deployment verifies.
const typehashes = await bz.signing.verifyTypehashes();
if (!typehashes.ok) throw new Error(`EIP-712 mismatch: ${typehashes.mismatches.join("; ")}`);

bz.signing.verifyTypehashes(pair?) compares every type string the SDK signs with the lens’s getTypehashes(), the domain’s name and version hashes and the fee and deadline caps with getEip712Constants(), and, given a pair, the domain separator with the pair’s DOMAIN_SEPARATOR(). It returns { ok, mismatches, typehashes, constants, domain? }. Run it once at startup.

Verifying

interface Answer {
  status: number;
  body: { hash?: Hex; error?: string; problems?: readonly { field: string; message: string }[] };
}

async function relayOne(body: string): Promise<Answer> {
  let req: relay.RelayedRequest;
  try {
    req = relay.parseRelayed(body); // shape and types; the signature canonicalized
  } catch (e) {
    return { status: 400, body: { error: e instanceof InputError ? e.message : "expected a JSON request" } };
  }
  if (req.relayerFee < MIN_FEE) {
    return { status: 402, body: { error: `this relayer charges at least ${formatUnits18(MIN_FEE)} USDC per request` } };
  }

  const check = await bz.relay.verifyRelayed(req); // signer, chain, deadline, nonce, fee cap, bounds, permit
  if (!check.ok) return { status: 422, body: { error: "the request would revert", problems: check.problems } };
  for (const w of check.warnings) console.warn(`${req.signer}: ${w}`);
  if (check.secondsLeft < 2) return { status: 422, body: { error: "the signature expires too soon: sign again" } };

  // Built now: a fresh price update and fresh book hints, outside the signature.
  const call = await bz.relay.encodeRelayed(req, { relayer: account.address });
  if (call.value > MAX_PYTH_FEE) return { status: 503, body: { error: "the price update fee is unusually high; try again" } };

  const sim = await bz.simulate(call);
  if (!sim.ok) return { status: 422, body: { error: sim.error!.message } };

  try {
    const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
    console.log(`${call.summary}: ${hash}`);
    return { status: 202, body: { hash } };
  } catch (e) {
    return { status: 502, body: { error: errorMessage(e) } };
  }
}

bz.relay.verifyRelayed(req) checks everything that would make the call revert, and returns a RelayCheck instead of throwing:

FieldMeaning
okSafe to submit now: problems is empty.
signerThe address the signature recovers to, or null.
problems{ field, message } for each thing that would revert: the pair is not the deployment’s (nothing else is read then), the chain id, a signature that does not recover to signer over exactly these fields or is not in canonical form, a deadline that has passed or is more than 10 seconds ahead of chain time, a nonce that is not the signer’s current one (used, or ahead: “an earlier request has to land first”), a fee above the cap, and the action’s own bounds.
warningsWhat would not revert by itself: a permit that would fail (wrong signer, used nonce, expired, too small, bad v or s), after which the call needs an allowance already in place; a terminated-market withdrawal that pays nothing, whose fee then comes from the wallet.
secondsLeftSeconds until the deadline by chain time (negative once passed).

permits.checkPermit(ctx, permitData, { owner, spender, needUsdc }) is the permit part on its own, returning { ok, problems, value }.

Encoding and Sending

bz.relay.encodeRelayed(req, opts?) builds the PreparedCall the relayer sends: the signed fields, the signature and permit, plus what sits outside the signature, built now:

  • a fresh price update for the actions that take one (withdrawCollateral, depositToInsurance, executeInsuranceWithdrawal, createOrder, amendOrders), with value its Pyth fee, which the relayer pays. createOrder and amendOrders always carry one, so the relayer’s connection needs a price source;
  • fresh MatchHints for createOrder (or opts.hints). A relayed order always walks the protocol’s default budget. Amendment hints are inside the signed words and cannot change.
OptionMeaning
relayerThe relayer’s address, used as the call’s from (so bz.simulate(call) runs as the relayer). Without it from is null.
hintsMatchHints for a createOrder, instead of the ones the SDK finds.

The call’s pullsUsdc is what it takes from the signer’s wallet, and its summary reads Relay for 0x...: Buy 0.5 at 2950 (limit, GTC) (fee 0.05 USDC). It throws InputError for a request signed for another chain or a signature not in canonical form (parseRelayed produces the canonical form).

Send it at once, from the relayer’s own key, as on Sending From a Bot. Simulate first: the relayer pays the gas of a call that reverts.

Ordering

// A signer's requests on one pair land only in nonce order, so handle them one at a time, each after
// the previous one has been mined. Different signers run in parallel.
const queues = new Map<string, Promise<unknown>>();

function inOrder<T>(key: string, job: () => Promise<T>): Promise<T> {
  const prev = queues.get(key) ?? Promise.resolve();
  const run = prev.then(job, job);
  const tail = run.catch(() => undefined);
  queues.set(key, tail);
  void tail.then(() => {
    if (queues.get(key) === tail) queues.delete(key);
  });
  return run;
}

async function relayInOrder(body: string): Promise<Answer> {
  let key = "malformed";
  try {
    const o = JSON.parse(body) as { pair?: unknown; signer?: unknown };
    key = `${String(o.pair).toLowerCase()}:${String(o.signer).toLowerCase()}`;
  } catch {
    // relayOne answers 400
  }
  return inOrder(key, async () => {
    const answer = await relayOne(body);
    if (answer.body.hash) await bz.waitForReceipt(answer.body.hash, { timeoutMs: 30_000 }).catch(() => undefined);
    return answer;
  });
}

Requests from one signer on one pair can only land in nonce order, so the server handles them one at a time and waits for each to be mined before the next. Requests from different signers do not wait for each other. The relayer’s own transaction nonces are handled by viem’s nonceManager.

The Server

const MAX_BODY = 16 * 1024;
const perIp = new Map<string, { count: number; since: number }>();

function rateLimited(ip: string): boolean {
  const now = Date.now();
  const e = perIp.get(ip);
  if (!e || now - e.since > 60_000) {
    perIp.set(ip, { count: 1, since: now });
    return false;
  }
  return ++e.count > 30; // 30 requests a minute per address
}

function readBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks: Buffer[] = [];
    req.on("data", (c: Buffer) => {
      size += c.length;
      if (size > MAX_BODY) {
        reject(new Error("body too large"));
        req.destroy();
      } else chunks.push(c);
    });
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

function send(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, {
    "content-type": "application/json",
    "access-control-allow-origin": ALLOWED_ORIGIN,
    "access-control-allow-headers": "content-type",
  });
  res.end(status === 204 ? undefined : JSON.stringify(body));
}

createServer(async (req, res) => {
  if (req.method === "OPTIONS") return send(res, 204, {});
  if (req.method !== "POST" || req.url !== "/relay") return send(res, 404, { error: "POST /relay" });
  if (rateLimited(req.socket.remoteAddress ?? "?")) return send(res, 429, { error: "slow down" });
  try {
    const answer = await relayInOrder(await readBody(req));
    send(res, answer.status, answer.body);
  } catch (e) {
    console.error(e);
    send(res, 500, { error: "the relayer failed; try again" });
  }
}).listen(PORT, () => console.log(`relaying as ${account.address} on :${PORT}`));

Limit body size and request rate: a request costs the relayer chain reads before it can be refused. Set access-control-allow-origin to your frontend’s origin.

Claiming the Fees

// Every hour, collect the fees earned on each market (claimFees refuses when nothing is owed).
async function claimAll(): Promise<void> {
  for (const m of await bz.markets.listMarkets({ refresh: true })) {
    try {
      const call = await bz.trade.claimFees(m.address, account.address);
      const sim = await bz.simulate(call);
      if (!sim.ok) continue;
      const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
      await bz.waitForReceipt(hash);
      console.log(`${m.label}: ${call.summary}`);
    } catch (e) {
      if (!(e instanceof InputError)) console.error(`${m.label}: ${errorMessage(e)}`);
    }
  }
}
setInterval(() => void claimAll(), 60 * 60 * 1000);

Fees accrue to the relayer’s claimable balance on each pair, alongside any integrator fees if the relayer’s address is also the frontend’s integrator. claimFees pays it all out and has no gasless form (Managing Orders).

Lower-Level Pieces

The signing namespace has everything signRelayed is built from, for a relayer in another language or a custom signer:

ExportWhat it is
TYPE_STRINGS, BAZAAR_TYPESThe eight primary types, as strings and as viem / eth_signTypedData_v4 field lists.
pairDomain(chainId, pair), domainSeparator(chainId, pair)The pair’s EIP-712 domain ("BazaarPair", version "1") and its separator.
typedDataFor(chainId, pair, fields, meta)The typed data a user signs. typedDataJson(td) is its JSON for eth_signTypedData_v4; relayedDigest(td) its digest.
typeHash(primaryType)keccak256 of a type string.
signTypedDataV4(wallet, signer, td)Sign through a wallet, normalized and checked to recover to signer.
normalizeSignature(sig)A signature in the 65-byte, v 27/28, low-s form the pair accepts.
encodePairWrite(fields, extra, meta)The calldata of any of the eight actions, direct or relayed.
RELAYED_ACTIONS, PRIMARY_TYPE, takesPriceUpdate(action)The relayable actions, their primary types, and which take a price update.
MAX_RELAYER_FEE, MAX_DEADLINE_WINDOW, MAX_CLOCK_SKEW, DEV_CHAIN_IDS$1, 10 s, 120 s, [31337, 1337].

In the relay namespace: describeRelayed(fields) (one line for a request), relayedPull and checkSignedFields (above).

What Can Go Wrong

You seeIt means
SigningError: “You declined the signature.”The user rejected the prompt.
SigningError: “… does not sign as a plain account …”A smart-contract wallet. Send directly instead.
ClockSkewErrorThe device’s clock and the RPC’s chain time disagree by more than 2 minutes.
InputError on relayerFee: “A relayer fee is at most $1 per action.”The fee cap.
InputError on relayerFee: “The relayer fee must be less than the deposit.”A deposit must exceed its fee.
MetaTx__ExpiredDeadline / deadline: expired ...The request took longer than its window. Sign again.
MetaTx__DeadlineTooFarA deadline more than 10 seconds ahead of the block.
MetaTx__InvalidNonce / nonce: ... is already usedAnother request from this signer landed first. Sign again.
nonce: ... is ahead of the signer's next nonceAn earlier request has to land first.
MetaTx__InvalidSignatureThe signature does not match the request (a field changed after signing).
MetaTx__RelayerFeeExceedsMaxThe fee is above the deployment’s cap.
BazaarPair__AmountLteRelayerFeeA withdrawal’s payout does not cover the fee.
permit: ... warningThe permit will fail; the call then needs an allowance in place.