Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Auction Relayer API

The Auction Relayer is a WebSocket service that brokers a request-for-quote (RFQ) flow between two roles:

  • Predictor — initiates an auction by broadcasting picks and the collateral they want to commit. Authenticated by an EIP-712 AuctionIntent signature.
  • Counterparty — typically a vault bot, watches every auction and submits a BidPayload containing the EIP-712 MintApproval signature that lets the predictor mint the on-chain prediction.

The relayer never holds custody. It validates messages, broadcasts auctions, and streams bids — the actual mint happens on-chain when the predictor calls PredictionMarketEscrow.mint() with both signatures.

Endpoint

wss://relayer.sapience.xyz/auction

The same host serves three logical channels on different message-type prefixes:

ChannelMessage types
Escrow auctionsauction.start, auction.subscribe, bid.submit, auction.received
Secondary marketsecondary.auction.start, secondary.bid.submit, secondary.feed.subscribe, secondary.listings.request
Vault quotesvault_quote.subscribe, vault_quote.update

All three share the same connection. There is no separate URL per channel.

Identifying your client

Right after open, send an identify message so the relayer can label your connection in metrics and logs. This is optional but recommended for any long-running bot.

ws.send(
  JSON.stringify({
    type: 'identify',
    payload: {
      service: 'my-vault-bot',
      instanceId: '<stable-per-process-uuid>',
      chainId: 5064014,
      version: '1.0.0',
    },
  })
);

See IdentifyPayload below for the optional fields (serviceInstance, variant, etc.).

Escrow auction flow

Predictor                    Relayer                  Counterparty (vault)
    │                           │                            │
    │── auction.start ─────────▶│                            │
    │◀─ auction.ack ────────────│                            │
    │                           │── auction.started ────────▶│
    │                           │                            │
    │                           │◀──────────── bid.submit ───│
    │                           │── bid.ack ────────────────▶│
    │◀─ auction.bids ───────────│                            │
    │                           │                            │
    │  (predictor selects a bid, calls escrow.mint() on-chain)
  1. Predictor sends auction.start carrying an AuctionRFQPayload with their picks, predictor address, nonce, deadline, and an EIP-712 AuctionIntent signature.
  2. Relayer validates the payload + intent signature, assigns an auctionId, replies with auction.ack, and broadcasts auction.started to every connected client.
  3. One or more counterparties reply with bid.submit carrying a BidPayload — their collateral, nonce, deadline, and an EIP-712 MintApproval signature.
  4. The predictor's connection receives every accepted bid via auction.bids, picks one, and calls mint() on the escrow contract with both signatures.

The predictor's WS connection is automatically subscribed to its own auction's bid stream.

Step 1 — Predictor: sign and send the RFQ

The predictor signs an EIP-712 AuctionIntent over their picks, predictor address, collateral, nonce, and deadline. The verifying contract is the escrow address for the chain.

import WebSocket from 'ws';
import { privateKeyToAccount } from 'viem/accounts';
import { buildAuctionIntentTypedData } from '@sapience/sdk/auction/escrowSigning';
import { contracts } from '@sapience/sdk/contracts';
 
const chainId = 5064014;
const escrowContract = contracts.predictionMarketEscrow[chainId].address;
const predictor = privateKeyToAccount('0x...');
 
const picks = [
  {
    conditionResolver: '0x49E848A5aBb356c40b025e05Dd0F7eFE95721d55',
    conditionId: '0x...',
    predictedOutcome: 1, // 0 = NO, 1 = YES
  },
];
 
const predictorCollateral = 1_000_000_000_000_000_000n; // 1 wUSDe (18 dec)
const predictorNonce = BigInt(Date.now());
const predictorDeadline = BigInt(Math.floor(Date.now() / 1000) + 600);
 
const typedData = buildAuctionIntentTypedData({
  picks,
  predictor: predictor.address,
  predictorCollateral,
  predictorNonce,
  predictorDeadline,
  verifyingContract: escrowContract,
  chainId,
});
 
const intentSignature = await predictor.signTypedData(typedData);
 
const ws = new WebSocket('wss://relayer.sapience.xyz/auction');
ws.on('open', () => {
  ws.send(
    JSON.stringify({
      type: 'auction.start',
      payload: {
        picks: picks.map((p) => ({
          conditionResolver: p.conditionResolver,
          conditionId: p.conditionId,
          predictedOutcome: p.predictedOutcome,
        })),
        predictor: predictor.address,
        predictorCollateral: predictorCollateral.toString(),
        predictorNonce: Number(predictorNonce),
        predictorDeadline: Number(predictorDeadline),
        intentSignature,
        chainId,
        escrowContract,
      },
    })
  );
});

escrowContract is required — without it the relayer rejects the request with error: 'missing_escrow_contract'. Pass an id field on the outer message to receive it back on the ack for client-side correlation.

intentSignature is optional at the protocol level, but vault counterparties only respond with actionable bids to signed RFQs. Treat unsigned RFQs as price-discovery only.

Step 2 — Counterparty: bid with a MintApproval

The counterparty signs an EIP-712 MintApproval over the prediction hash (which deterministically commits to picks + both collaterals + both party addresses + sponsor data) and sends it as a bid.submit.

import { buildCounterpartyMintTypedData } from '@sapience/sdk/auction/escrowSigning';
 
ws.on('message', async (raw) => {
  const msg = JSON.parse(String(raw));
  if (msg.type !== 'auction.started') return;
 
  const auction = msg.payload; // AuctionDetails
  const counterpartyCollateral = pickQuote(auction); // your strategy
  const counterpartyNonce = BigInt(Date.now());
  const counterpartyDeadline = BigInt(Math.floor(Date.now() / 1000) + 60);
 
  const typedData = buildCounterpartyMintTypedData({
    picks: auction.picks,
    predictorCollateral: BigInt(auction.predictorCollateral),
    counterpartyCollateral,
    predictor: auction.predictor,
    counterparty: counterparty.address,
    counterpartyNonce,
    counterpartyDeadline,
    verifyingContract: auction.escrowContract,
    chainId: auction.chainId,
  });
 
  const counterpartySignature = await counterparty.signTypedData(typedData);
 
  ws.send(
    JSON.stringify({
      type: 'bid.submit',
      payload: {
        auctionId: auction.auctionId,
        counterparty: counterparty.address,
        counterpartyCollateral: counterpartyCollateral.toString(),
        counterpartyNonce: Number(counterpartyNonce),
        counterpartyDeadline: Number(counterpartyDeadline),
        counterpartySignature,
      },
    })
  );
});

The relayer runs Tier 1 validateBid(): it rejects malformed or expired bids and invalid signature formats, verifies signatures offline when possible, and relays unverifiable smart-contract-style signatures as passthrough. The on-chain mint() remains the authoritative gate.

Step 3 — Predictor: select a bid and mint

Bids arrive on the predictor's connection as auction.bids messages. Pick the best bid by counterpartyCollateral (higher is better — the counterparty is putting up more against the predictor's stake), then assemble an AuctionRequestPayload client-side using @sapience/sdk/auction/buildAuctionPayload and call mint() on the escrow contract. The on-chain mint never goes through the relayer.

A reference end-to-end example lives in starters/market-maker/.

Smart-account predictors

The relayer accepts intent signatures from smart accounts via four offline verification paths, plus an optional ERC-1271 RPC fallback:

  1. Escrow session key (ABI-encoded predictorSessionKeyData): the relayer decodes the SessionKeyApproval, verifies the owner authorized the session key, then checks the intentSignature was produced by that key.
  2. EOA: standard recoverTypedDataAddress against the predictor address.
  3. Smart-account owner via CREATE2: recovers the EOA, derives the smart account address from it, checks against the predictor.
  4. ZeroDev session approval (JSON predictorSessionKeyData): parses { approval, typedData }, verifies the session key approval, then checks the intent signature recovers to the authorized session key.
  • Optional ERC-1271 fallback: when an RPC client is configured, the relayer calls isValidSignature on the predictor contract after the offline paths fail. Signatures that can't be verified offline are still accepted as unverified passthrough, since the on-chain mint() is the authoritative gate.

predictorSessionKeyData accepts two formats:

  • ABI-encoded hex (escrow-native session keys): 0x followed by an abi.encode((address sessionKey, address owner, uint256 validUntil, bytes32 permissionsHash, uint256 chainId, bytes ownerSignature)) payload.
  • JSON string (ZeroDev session approval): {"approval":"<base64>","typedData":{...}}.

There is no top-level sessionTypedData field on AuctionRFQPayload — for ZeroDev session keys, embed it inside predictorSessionKeyData.

Other channels

Secondary market

Atomic OTC swap of position tokens after mint. Roles invert: the seller lists position tokens, the buyer bids collateral. Same WebSocket connection; message-type prefix secondary..

A quoteOnly: true flag on secondary.auction.start marks a price-discovery request — the listing is excluded from the snapshot returned by secondary.listings.request and broadcast events flag it so observers can ignore quote traffic.

Vault quotes

Subscribe with {type: 'vault_quote.subscribe', payload: {chainId, vaultAddress}} and receive vault_quote.update messages carrying vaultCollateralPerShare. Used by the app to display live vault pricing without polling RPC.

Validation

auction.start is rejected if any of these checks fail. The relayer returns the human-readable reason from the SDK in auction.ack.payload.error; the SDK's machine-readable ValidationErrorCode enum is in packages/sdk/auction/validation.ts. missing_escrow_contract is a relayer-level wire error.

CodeCause
missing_escrow_contractescrowContract field is absent. Relayer-level.
MISSING_FIELDRequired field on the payload is missing.
INVALID_PICKSPicks array is empty, malformed, or contains an invalid pick.
EXPIRED_DEADLINEpredictorDeadline is in the past.
DEADLINE_TOO_FARDeadline exceeds maxDeadlineSeconds (default 7200s).
INVALID_SIGNATURESignature does not recover to predictor and no smart-account path matches.
SIGNATURE_UNVERIFIABLESmart-account signature couldn't be verified offline; passed through to on-chain mint.
CHAIN_MISMATCHchainId doesn't match the configured value for verification.
VALIDATION_ERRORUnexpected validation error.

bid.submit performs Tier 1 validation: field/deadline/auction checks plus offline signature verification when possible. Malformed bids are rejected; valid or unverifiable smart-contract signatures are relayed, with final authority on-chain. The SDK exposes validateBidOnChain() and simulateMint() for callers that want pre-submit confidence.

Connection limits

  • Rate limit: 100 messages per 10 seconds per connection. Excess messages are dropped; sustained abuse closes the connection with code 1008.
  • Message size: 64 KB max. Larger messages close the connection with 1009.
  • Per-IP cap: 50 concurrent connections by default. Configurable via the relayer's WS_MAX_CONNECTIONS_PER_IP.
  • Validation-failure penalty: 10 signature-validation failures or 20 invalid-message failures within a session disconnect the client and apply a per-IP cooldown.
  • Idle timeout: 5 minutes with no messages closes the connection. Send {type: 'ping'} to keep alive; the relayer replies {type: 'pong'}.

Type reference

Escrow auction types

The primary auction protocol — predictor RFQ, vault/counterparty bid, on-chain mint. Terminology: predictor initiates the request; counterparty (typically a vault bot) fills it.

AuctionRFQPayload

/**
 * Step 1: RFQ intent — predictor broadcasts intent, no signature, no counterparty info.
 * The vault determines counterpartyCollateral (the quote).
 */
interface AuctionRFQPayload {
  picks: PickJson[];
  predictorCollateral: string;
  counterpartyCollateral?: string;
  predictor: string;
  predictorNonce: number;
  predictorDeadline: number;
  intentSignature?: string;
  chainId: number;
  escrowContract: string;
  refCode?: string;
  predictorSessionKeyData?: string;
  predictorSponsor?: string;
  predictorSponsorData?: string;
}

BidPayload

/**
 * Escrow bid payload - counterparty fills an auction
 */
interface BidPayload {
  auctionId: string;
  counterparty: string;
  counterpartyCollateral: string;
  counterpartyNonce: number;
  counterpartyDeadline: number;
  counterpartySignature: string;
  counterpartySessionKeyData?: string;
}

AuctionDetails

/** Broadcast to vaults when an auction starts — no counterpartyCollateral (vault decides) */
interface AuctionDetails {
  auctionId: string;
  picks: PickJson[];
  predictorCollateral: string;
  counterpartyCollateral?: string;
  predictor: string;
  predictorNonce: number;
  predictorDeadline: number;
  intentSignature?: string;
  predictorSessionKeyData?: string;
  chainId: number;
  escrowContract: string;
  createdAt: string;
  predictorSponsor?: string;
  predictorSponsorData?: string;
}

ValidatedBid

/** Bid that has been validated */
interface ValidatedBid {
  auctionId: string;
  counterparty: string;
  counterpartyCollateral: string;
  counterpartyNonce: number;
  counterpartyDeadline: number;
  counterpartySignature: string;
  counterpartySessionKeyData?: string;
  receivedAt: string;
}

IdentifyPayload

/**
 * Client identity announced after WS connect. Backwards-compatible:
 * relayer treats clients without identify as anonymous.
 *
 * Three identity layers stack here:
 *   - clientId  (assigned by relayer per WS connection — changes on every reconnect)
 *   - instanceId (assigned by client per process — stable across reconnects, changes on restart)
 *   - service   (logical service name — non-unique by design)
 */
interface IdentifyPayload {
  service: string;
  instanceId: string;
  chainId?: number;
  bootAt?: number;
  version?: string;
  deploymentId?: string;
  replicaId?: string;
  /**
   * Optional human-readable label for the deployment hosting this process
   * (e.g. Railway service name). Distinguishes two deployments that share the
   * same logical `service` — for instance an `auction-bidder` running the Pyth
   * pricing strategy vs the default strategy in a separate Railway service.
   */
  serviceInstance?: string;
  /**
   * Optional bounded label for which strategy/vault flavor this bot serves
   * (e.g. `'default'`, `'pyth'`, `'experimental'`). Lets metrics break out
   * `auction-bidder` connections by which vault they bid against without
   * exploding cardinality. Relayer collapses any value outside its allowlist
   * to `'unknown'`.
   */
  variant?: string;
}

AuctionReceivedPayload

/** Optional ack a client sends on receiving auction.started — proves end-to-end delivery. */
interface AuctionReceivedPayload {
  auctionId: string;
}

ClientToServerMessage

type ClientToServerMessage =
  | {
      type: 'auction.start';
      payload: AuctionRFQPayload;
    }
  | {
      type: 'auction.subscribe';
      payload: {
        auctionId: string;
      };
    }
  | {
      type: 'auction.unsubscribe';
      payload: {
        auctionId: string;
      };
    }
  | {
      type: 'bid.submit';
      payload: BidPayload;
    }
  | {
      type: 'identify';
      payload: IdentifyPayload;
    }
  | {
      type: 'auction.received';
      payload: AuctionReceivedPayload;
    }
  | {
      type: 'ping';
    };

ServerToClientMessage

type ServerToClientMessage =
  | {
      type: 'auction.ack';
      payload: {
        auctionId?: string;
        error?: string;
        subscribed?: boolean;
        unsubscribed?: boolean;
        id?: string;
      };
    }
  | {
      type: 'bid.ack';
      payload: {
        bidId?: string;
        error?: string;
      };
    }
  | {
      type: 'auction.started';
      payload: AuctionDetails;
    }
  | {
      type: 'auction.bids';
      payload: {
        auctionId: string;
        bids: ValidatedBid[];
      };
    }
  | {
      type: 'auction.filled';
      payload: {
        auctionId: string;
        predictionId: string;
        pickConfigId: string;
        transactionHash: string;
      };
    }
  | {
      type: 'auction.expired';
      payload: {
        auctionId: string;
        reason: string;
      };
    }
  | {
      type: 'pong';
    }
  | {
      type: 'error';
      payload: {
        message: string;
        code?: string;
      };
    };

Secondary market types

Atomic OTC swap of position tokens after mint. Terminology: seller lists tokens; buyer bids collateral.

SecondaryAuctionRequestPayload

/**
 * Secondary auction request - seller lists position tokens for sale
 * Seller signs the trade approval, bots compete to fill as buyer
 */
interface SecondaryAuctionRequestPayload {
  token: string;
  collateral: string;
  tokenAmount: string;
  seller: string;
  sellerNonce: number;
  sellerDeadline: number;
  sellerSignature: string;
  chainId: number;
  escrowContract: string;
  refCode?: string;
  sellerSessionKeyData?: string;
  /** When true, this is a price discovery request — not a real listing.
   *  Relayer will exclude it from the listings snapshot and include quoteOnly
   *  on feed broadcasts so observers can identify it as quote traffic.
   *  The vault quoter responds with a simplified price. */
  quoteOnly?: boolean;
}

SecondaryBidPayload

/**
 * Secondary bid - buyer offers to purchase position tokens
 */
interface SecondaryBidPayload {
  auctionId: string;
  buyer: string;
  price: string;
  buyerNonce: number;
  buyerDeadline: number;
  buyerSignature: string;
  buyerSessionKeyData?: string;
}

SecondaryAuctionDetails

/** Secondary auction details broadcast to subscribers */
interface SecondaryAuctionDetails {
  auctionId: string;
  token: string;
  collateral: string;
  tokenAmount: string;
  seller: string;
  sellerDeadline: number;
  chainId: number;
  escrowContract: string;
  createdAt: string;
  /** True when this is a price discovery request (not a real listing) */
  quoteOnly?: boolean;
}

SecondaryValidatedBid

/** Validated secondary bid */
interface SecondaryValidatedBid {
  auctionId: string;
  buyer: string;
  price: string;
  buyerNonce: number;
  buyerDeadline: number;
  buyerSignature: string;
  buyerSessionKeyData?: string;
  receivedAt: string;
}

SecondaryListingSummary

/** Listing summary returned in listings snapshot */
interface SecondaryListingSummary {
  auctionId: string;
  token: string;
  collateral: string;
  tokenAmount: string;
  seller: string;
  sellerDeadline: number;
  chainId: number;
  escrowContract: string;
  createdAt: string;
  bidCount: number;
}

SecondaryClientToServerMessage

type SecondaryClientToServerMessage =
  | {
      type: 'secondary.auction.start';
      payload: SecondaryAuctionRequestPayload;
    }
  | {
      type: 'secondary.auction.subscribe';
      payload: {
        auctionId: string;
      };
    }
  | {
      type: 'secondary.auction.unsubscribe';
      payload: {
        auctionId: string;
      };
    }
  | {
      type: 'secondary.bid.submit';
      payload: SecondaryBidPayload;
    }
  | {
      type: 'secondary.feed.subscribe';
    }
  | {
      type: 'secondary.feed.unsubscribe';
    }
  | {
      type: 'secondary.listings.request';
    }
  | {
      type: 'ping';
    };

SecondaryServerToClientMessage

type SecondaryServerToClientMessage =
  | {
      type: 'secondary.auction.ack';
      payload: {
        auctionId?: string;
        error?: string;
        subscribed?: boolean;
        unsubscribed?: boolean;
      };
    }
  | {
      type: 'secondary.bid.ack';
      payload: {
        bidId?: string;
        error?: string;
      };
    }
  | {
      type: 'secondary.auction.started';
      payload: SecondaryAuctionDetails;
    }
  | {
      type: 'secondary.auction.bids';
      payload: {
        auctionId: string;
        bids: SecondaryValidatedBid[];
      };
    }
  | {
      type: 'secondary.auction.filled';
      payload: {
        auctionId: string;
        tradeHash: string;
        transactionHash: string;
      };
    }
  | {
      type: 'secondary.auction.expired';
      payload: {
        auctionId: string;
        reason: string;
      };
    }
  | {
      type: 'secondary.listings.snapshot';
      payload: {
        listings: SecondaryListingSummary[];
      };
    }
  | {
      type: 'pong';
    }
  | {
      type: 'error';
      payload: {
        message: string;
        code?: string;
      };
    };