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
AuctionIntentsignature. - Counterparty — typically a vault bot, watches every auction and submits a
BidPayloadcontaining the EIP-712MintApprovalsignature 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/auctionThe same host serves three logical channels on different message-type prefixes:
| Channel | Message types |
|---|---|
| Escrow auctions | auction.start, auction.subscribe, bid.submit, auction.received |
| Secondary market | secondary.auction.start, secondary.bid.submit, secondary.feed.subscribe, secondary.listings.request |
| Vault quotes | vault_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)- Predictor sends
auction.startcarrying anAuctionRFQPayloadwith their picks, predictor address, nonce, deadline, and an EIP-712AuctionIntentsignature. - Relayer validates the payload + intent signature, assigns an
auctionId, replies withauction.ack, and broadcastsauction.startedto every connected client. - One or more counterparties reply with
bid.submitcarrying aBidPayload— their collateral, nonce, deadline, and an EIP-712MintApprovalsignature. - The predictor's connection receives every accepted bid via
auction.bids, picks one, and callsmint()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:
- Escrow session key (ABI-encoded
predictorSessionKeyData): the relayer decodes the SessionKeyApproval, verifies the owner authorized the session key, then checks theintentSignaturewas produced by that key. - EOA: standard
recoverTypedDataAddressagainst the predictor address. - Smart-account owner via CREATE2: recovers the EOA, derives the smart account address from it, checks against the predictor.
- 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
isValidSignatureon the predictor contract after the offline paths fail. Signatures that can't be verified offline are still accepted asunverifiedpassthrough, since the on-chainmint()is the authoritative gate.
predictorSessionKeyData accepts two formats:
- ABI-encoded hex (escrow-native session keys):
0xfollowed by anabi.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.
| Code | Cause |
|---|---|
missing_escrow_contract | escrowContract field is absent. Relayer-level. |
MISSING_FIELD | Required field on the payload is missing. |
INVALID_PICKS | Picks array is empty, malformed, or contains an invalid pick. |
EXPIRED_DEADLINE | predictorDeadline is in the past. |
DEADLINE_TOO_FAR | Deadline exceeds maxDeadlineSeconds (default 7200s). |
INVALID_SIGNATURE | Signature does not recover to predictor and no smart-account path matches. |
SIGNATURE_UNVERIFIABLE | Smart-account signature couldn't be verified offline; passed through to on-chain mint. |
CHAIN_MISMATCH | chainId doesn't match the configured value for verification. |
VALIDATION_ERROR | Unexpected 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;
};
};