Skip to content

Sign an order

A LootFi listing is an EIP-712 signature, not a transaction. Signing costs no gas, moves nothing, and locks nothing. The order becomes real only when someone fills it.

Design rationale: Marketplace.

Before you sign

Four conditions, or the order can never be filled:

  1. The maker owns the token.
  2. LootMarket is approved — either setApprovalForAll(market, true) or approve(market, tokenId). This is an on-chain transaction and costs gas.
  3. expiry is in the future.
  4. currency is address(0) (native ETH) or an allow-listed ERC-20. No ERC-20 is allow-listed today, so use address(0).

The typed data

ts
const domain = {
  name: "LootMarket",
  version: "1",
  chainId: 4663,
  verifyingContract: LOOT_MARKET,   // see /contracts/deployments
} as const;

const types = {
  Order: [
    { name: "maker",      type: "address" },
    { name: "collection", type: "address" },
    { name: "tokenId",    type: "uint256" },
    { name: "currency",   type: "address" },
    { name: "price",      type: "uint256" },
    { name: "expiry",     type: "uint256" },
    { name: "nonce",      type: "uint256" },
  ],
} as const;

Field order is part of the hash

The struct hash is computed over the fields in exactly this order. Reordering them produces a different hash, a signature that recovers to the wrong address, and a fill that reverts with InvalidSignature.

The domain binds both chainId and verifyingContract, so a signature cannot be replayed on another chain or against a different marketplace.

Signing with viem

ts
const order = {
  maker: account,
  collection: LOOT_SKINS,
  tokenId: 123n,
  currency: "0x0000000000000000000000000000000000000000", // native ETH
  price: parseEther("0.05"),
  expiry: BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 3600),
  nonce: await pickNonce(account),
} as const;

const signature = await walletClient.signTypedData({
  account,
  domain,
  types,
  primaryType: "Order",
  message: order,
});

Signing with ethers v6

ts
const signature = await signer.signTypedData(domain, types, {
  maker: order.maker,
  collection: order.collection,
  tokenId: order.tokenId,
  currency: order.currency,
  price: order.price,
  expiry: order.expiry,
  nonce: order.nonce,
});

ethers infers EIP712Domain itself — do not add it to types or the hash will not match.

Choosing a nonce the contract will accept

A nonce is per-maker and single-use. The contract rejects any nonce that is already in usedOrCancelled, or below the maker's minimumNonce watermark.

ts
async function pickNonce(maker: `0x${string}`): Promise<bigint> {
  const minNonce = await publicClient.readContract({
    ...market, functionName: "minimumNonce", args: [maker],
  });

  // A timestamp is a convenient monotonic starting point, but it MUST be
  // raised above the watermark — a bulk cancel can set minimumNonce far
  // ahead of the current time.
  let nonce = BigInt(Math.floor(Date.now() / 1000));
  if (nonce < minNonce) nonce = minNonce;

  // Walk forward past anything already consumed.
  while (await publicClient.readContract({
    ...market, functionName: "usedOrCancelled", args: [maker, nonce],
  })) {
    nonce += 1n;
  }
  return nonce;
}

Verify your own signature before relying on it

Cheaper than discovering the problem when a buyer's fill reverts:

ts
const onChainHash = await publicClient.readContract({
  ...market, functionName: "hashOrder", args: [order],
});

const recovered = await recoverTypedDataAddress({
  domain, types, primaryType: "Order", message: order, signature,
});

if (recovered.toLowerCase() !== order.maker.toLowerCase()) {
  throw new Error("signature does not recover to maker");
}

domainSeparator() is also exposed if you are building the hash by hand.

Cancelling

solidity
function cancel(uint256 nonce) external;              // one order
function setMinimumNonce(uint256 minNonce) external;  // everything below minNonce

Both are on-chain transactions that cost gas.

setMinimumNonce is not scoped

It invalidates every order of yours below the watermark, including ones you intended to keep. Use cancel for a single listing; reserve the watermark for "kill everything I have signed".

Raising it is irreversible: the deployed contract rejects any attempt to lower the watermark, so signatures below it stay dead on chain permanently.

An order also dies without any transaction at all if you transfer the token away or revoke the market's approval — the fill will fail its ownership or approval check.

Smart-contract wallets are not supported

Verification uses ECDSA.recover, so only EOA signatures work. A Safe or an AA wallet cannot be an order maker — an ERC-1271 signature will not recover to order.maker, and the fill reverts with InvalidSignature. Sign from an EOA that holds the token.

LootFi is not affiliated with, endorsed by, or sponsored by Valve Corporation. Counter-Strike and Steam are trademarks of Valve Corporation.