Skip to main content

Documentation Index

Fetch the complete documentation index at: https://polynode.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Overview

The Fee Escrow lets platforms charge a fee on trades placed through the polynode SDK. Fees are optional, per-order, and fully on-chain. The system uses an escrow model: fees are pulled before order placement and either distributed on fill or refunded on cancel. Users never lose fees on unfilled orders. Key properties:
  • Fees are opt-in. Set feeBps: 0 to skip the escrow entirely.
  • Each order gets its own escrow entry with an independent affiliate and split.
  • If the operator doesn’t settle within 72 hours, the user can self-refund on-chain.
  • All escrow operations are gasless via Polymarket’s relayer.
This is your platform’s fee — separate from anything Polymarket charges.A user trading through your SDK may pay three distinct fees, each collected by a different system:
FeeWho charges itWhere it’s setHow it’s collected
Polymarket protocol feePolymarketV1 Order struct feeRateBps field. V2 has no per-order fee slot.Taken by the CLOB at settlement, goes to Polymarket’s treasury
Polymarket builder rev share (V2 only)PolymarketV2 Order struct builder bytes32 fieldPolymarket reads the field from the OrderFilled event and pays the builder rev share off-chain
Your platform fee (this guide)Your platformfeeConfig.feeBps in the polynode SDKPulled from the user’s collateral into our FeeEscrow contract before the order reaches the CLOB
All three are independent. feeBps: 0 turns off your platform fee only — it does not waive Polymarket’s protocol fee or builder rev share. Setting your feeBps does not change anything inside the signed CLOB Order struct.

Frequently Asked Questions

It doesn’t know anything about the Polymarket order. The amount is locked at deposit time. When pullFee is called, the contract stores escrows[orderId].amount = feeAmount. That number is immutable. distribute sends from that stored amount. refund sends back whatever’s left. The contract just moves money between three fixed addresses: the escrow pool, the affiliate wallet, and the treasury.
Three access controls, each with a single hardcoded destination:
  • distribute() requires onlyAuthorized (our operator wallet or contract owner). Sends funds only to the affiliate and treasury addresses stored at deposit time. Anyone else calling it gets Unauthorized.
  • refund() also requires onlyAuthorized. Sends funds only back to the original payer.
  • claimRefund() can be called by anyone, but the contract checks msg.sender == escrow.payer and block.timestamp > pulledAt + 72 hours. Only the original payer can claim, and only after 72h. Funds go exclusively to escrow.payer.
The affiliate address and share split are signed by the user in the EIP-712 FeeAuth message and stored on-chain at deposit time. Nobody can change them after deposit. The user signed it, the contract stored it, and that’s where the money goes.
The contract splits every distribution into two transfers:
  • affiliateShareBps / 10000 of the amount goes to the affiliate address (your wallet).
  • The remainder goes to the treasury address (set by the contract owner).
If affiliateShareBps is 10000 (the default), you keep 100% and nothing goes to the treasury. Both addresses are immutable for each escrow entry.
Three independent paths back to the user, any one of which is sufficient:
  1. Inline refund on cancel. When you call cancelOrder() or cancelAll(), our cosigner automatically calls refund() in the same request. The collateral returns to the user’s wallet immediately.
  2. Lifecycle refund. Our background service checks pending escrow orders every 30 seconds using on-chain verification. If an order has no fills after 72 hours, it calls refund() automatically.
  3. User self-refund. After 72 hours, the user (or anyone on their behalf) can call claimRefund(orderId) directly on the contract. No involvement from us needed. Funds go to the original payer, always.
These three paths are completely independent. If our backend is down, path 3 still works. If the user is offline, paths 1 and 2 still work.
No. Every escrowed fee has exactly two possible exits, and at least one is always available:
  • Distribute sends it to the affiliate and treasury.
  • Refund / claimRefund sends it back to the payer.
There is no expiration that burns funds. There is no third destination. If all automated paths fail, the contract owner has emergencyWithdraw as a last resort. And claimRefund is always available to the payer after 72 hours with zero dependencies on our infrastructure.
The only scenario where a refund doesn’t happen automatically: our cosigner is down at the moment of cancellation AND the lifecycle service is also down AND the user doesn’t call claimRefund themselves after 72 hours. In that case the funds sit in the contract, untouched, until someone acts. They don’t disappear, they don’t move, and no one else can take them. The moment any one of those three systems comes back online, the refund happens.
Our lifecycle service checks on-chain OrderFilled events every 30 seconds. Each partial fill emits an event with the exact amount filled. The service sums all fills, computes the fill fraction against the original order size, and distributes only the new portion since the last check. The contract tracks cumulative distributed amounts internally and caps at the total, so there’s no risk of over-distribution even if our math has a rounding error. Undistributed remainder is refunded after the 72-hour timeout.
Pure on-chain verification. The CLOB order ID is the EIP-712 hash of the signed order, and that same hash appears as an indexed topic in every on-chain OrderFilled event. Our lifecycle queries eth_getLogs with that hash across all Polymarket V2 exchange contracts. If the event exists on the blockchain, the fill happened. If it doesn’t exist, it didn’t. No API calls, no authentication, no trust assumptions. Immutable blockchain state is the source of truth.

V1 and V2 — which one do I use?

Two contracts are live. Both are fully supported; neither is being deprecated.
You’re trading on…UseCollateral token
V1 CLOB (clob.polymarket.com)FeeEscrow V1 0xa11D28433B79D0A88F3119b16A090075752258EAUSDC.e
V2 CLOB (clob-v2.polymarket.com)FeeEscrow V2 0x3A43D88ef8Aae4dF5a50B3abf67122CAAeEF7c9FpUSD
The two contracts have byte-identical calldata, identical function signatures, and identical behavior. The only differences are the collateral token (USDC.e vs pUSD) and the EIP-712 domain name / version. If you’re writing a new V2 integration you do not need to support V1 — just point at the V2 contract and sign against the V2 domain. Existing V1 integrations keep working unchanged — no migration required.

How It Works

1. User signs EIP-712 fee authorization (off-chain, free)
2. Operator calls pullFee → collateral moves from user's Safe to escrow
3. Order goes to Polymarket CLOB (unchanged, builder creds preserved)
4. On fill   → operator calls distribute  → fee splits to treasury + affiliate
   On cancel → operator calls refund      → collateral returns to user's Safe
   On timeout (72h) → user calls claimRefund → collateral returns automatically
The fee escrow is completely independent from the Polymarket order flow. Builder credentials, order signing, and CLOB submission are unchanged. The fee is a separate ERC-20 transfer (USDC.e on V1, pUSD on V2) that happens before the order.

Contract addresses

Both contracts are on Polygon mainnet (chain ID 137), both use the same 72-hour self-refund window, and both have the same FEE_AUTH_TYPEHASH.

V1 — USDC.e collateral (V1 CLOB)

FieldValue
Address0xa11D28433B79D0A88F3119b16A090075752258EA
CollateralUSDC.e 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 (6 decimals)
EIP-712 domainname="PolyNodeFeeEscrow", version="1", chainId 137

V2 — pUSD collateral (V2 CLOB, live 2026-04-24)

FieldValue
Address0x3A43D88ef8Aae4dF5a50B3abf67122CAAeEF7c9F
CollateralpUSD 0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB (6 decimals)
EIP-712 domainname="PolyNodeFeeEscrowV2", version="2", chainId 137

Setup

Users need one additional ERC-20 approval for the escrow contract — USDC.e for V1, pUSD for V2. This is added to the existing approval batch during wallet setup (7th approval, gasless, batched with the 6 Polymarket approvals).
// V1: ensureReady() approves USDC.e for the V1 FeeEscrow alongside
//     the six Polymarket approvals. No manual setup needed.
const status = await trader.ensureReady(privateKey);
For V2, set exchangeVersion: "v2" in your TraderConfig (TypeScript), exchange_version=ExchangeVersion.V2 (Python), or exchange_version: ExchangeVersion::V2 (Rust). ensureReady() then approves pUSD for the V2 FeeEscrow contract as part of the same gasless Safe approval batch as V1 does for USDC.e. Supported from SDK versions: [email protected] (TypeScript), polynode==0.9.2 (Python), polynode = "0.12.2" (Rust).

Fee Authorization (EIP-712)

The user signs a typed message authorizing a specific fee amount for a specific order. This prevents anyone from pulling more than the user agreed to. The FeeAuth struct is identical across V1 and V2 — only the domain changes.
// V1 domain (USDC.e collateral)
{
  name: "PolyNodeFeeEscrow",
  version: "1",
  chainId: 137,
  verifyingContract: "0xa11D28433B79D0A88F3119b16A090075752258EA"
}

// V2 domain (pUSD collateral)
{
  name: "PolyNodeFeeEscrowV2",
  version: "2",
  chainId: 137,
  verifyingContract: "0x3A43D88ef8Aae4dF5a50B3abf67122CAAeEF7c9F"
}

// FeeAuth struct — same for V1 and V2
{
  orderId: bytes32,   // unique order identifier
  payer: address,     // user's Safe wallet (where collateral is pulled from)
  signer: address,    // user's EOA (signs this message)
  feeAmount: uint256, // fee amount (6 decimals — USDC.e on V1, pUSD on V2)
  deadline: uint256,  // unix timestamp — authorization expires after this
  nonce: uint256      // signer's current nonce (prevents replay)
}

Selecting V2 at submit time

The polynode cosigner routes every fee request to V1 by default. To use V2, include the contract address in the fee_auth body of your /submit call:
// POST /submit to trade.polynode.dev
{
  "method": "POST",
  "path": "/order",
  "body": "...",                     // V2 CLOB order body
  "headers": { ... },                // CLOB L2 HMAC headers
  "fee_auth": {
    "escrow_contract": "0x3A43D88ef8Aae4dF5a50B3abf67122CAAeEF7c9F",  // ← routes to V2
    "escrow_order_id": "0x...",
    "payer":  "0xSafe...",
    "signer": "0xEOA...",
    "fee_amount": "27500",
    "deadline": 1777000000,
    "nonce": 0,
    "signature": "0x...",            // signed against the V2 domain above
    "affiliate": "0xPartnerWallet...",
    "affiliate_share_bps": 10000
  }
}
Omit escrow_contract (or set it to the V1 address) to use V1. Any other value falls through to V1, where the signature check will fail because the V2 domain separator won’t match — you’ll get an InvalidSignature revert rather than funds going to the wrong place.

Order Types

Every order type works with the escrow. The fee is pulled before submission and settled based on the order outcome.

Limit Buy (GTC, resting)

Order rests on the book. Fills at any point in the future distribute the fee normally. If cancelled before fill, the fee is refunded. Orders resting past 72 hours are covered explicitly in the 72-Hour Safety Net section below.
pullFee($0.005)  →  order BUY 5 @ $0.20  →  cancel  →  refund($0.005)
                                                         ↳ user gets fee back
Verified on Polygon — order 0x39cb2d... placed at 20c, cancelled, fee refunded. Safe net change: $0.00.

Market Buy (fills immediately)

Order fills instantly. Fee is distributed to treasury.
pullFee($0.005)  →  order BUY 5 @ $0.36  →  fills  →  distribute($0.005)
                                              ↳ 5 shares for $1.80    ↳ treasury receives fee
Verified on Polygon — matched, making=$1.80 taking=5 shares. Fee distributed to treasury.

Limit Sell (GTC, resting)

Same flow as limit buy. Rests on book, refunded on cancel.
pullFee($0.005)  →  order SELL 5 @ $0.45  →  cancel  →  refund($0.005)
Verified on Polygon — order 0x71f943... placed at 45c, cancelled, fee refunded.

Market Sell (fills immediately)

pullFee($0.005)  →  order SELL 5 @ $0.35  →  fills  →  distribute($0.005)
Verified on Polygon — matched, fee distributed.

Batch Cancel

Multiple resting orders can be cancelled at once. Each gets its own refund.
pullFee(order_a)  →  place order A
pullFee(order_b)  →  place order B
                     cancelAll()
                     refund(order_a)
                     refund(order_b)
Verified on Polygon — 2 orders cancelled via cancelAll(), both fees refunded.

No Fee (Opt-Out)

If feeBps is 0, the SDK skips the escrow entirely. The order goes straight to the CLOB with no fee interaction. Existing behavior is completely unchanged.

Affiliate Revenue Sharing

Each order can specify an affiliate address and their share of the fee. The split is set per-order, so different partners can have different rates.
// Example: partner keeps 90% of a $0.10 fee
pullFee({
  orderId: "0x...",
  feeAmount: 100000,    // $0.10
  affiliate: "0xPartnerWallet...",
  affShareBps: 9000,    // 90% to partner
})

// On distribute:
//   Partner receives: $0.090
//   Treasury receives: $0.010
SplitTreasuryAffiliate
affiliateShareBps: 10000 (default — you keep 100%)$0.00$0.10
affiliateShareBps: 7000 (share 30% with polynode)$0.03$0.07
affiliateShareBps: 5000 (50/50 split)$0.05$0.05
All splits verified on Polygon mainnet with real USDC transfers.

Partial Fills

If an order partially fills, the fee is proportionally distributed. The remainder stays in escrow until the order fully fills or is cancelled.
pullFee($0.20)
  → distribute(30%)  → $0.06 to treasury
  → distribute(40%)  → $0.08 to treasury
  → refund(rest)     → $0.06 back to user
Verified on Polygon — partial fills distribute proportionally, refund returns the exact remainder.

72-Hour Safety Net

The escrow contract has a 72-hour timeout. After that window, the user can always get their undistributed fee back.
// After 72 hours, anyone can call this
escrow.claimRefund(orderId);
// → undistributed USDC returns to the payer's Safe, always
Three things to know:
  1. The transfer target is hardcoded. claimRefund always sends funds back to the original payer, no matter who calls it. Nobody can steal anyone else’s fee. The reason anyone can trigger it (not just the payer) is purely so a bot or a friend can help a user recover funds if the user is offline.
  2. This is a fail-safe, not the expected path. It exists so that if our backend goes down, gets paused, or disappears entirely, users are guaranteed to get their escrowed money back without needing us. Most users will never know the function exists, and most will never call it.
  3. It does not fire automatically. The timeout doesn’t trigger anything on its own — it just unlocks the option for the user (or anyone acting on their behalf) to reclaim the fee. If nobody calls claimRefund, the escrow sits untouched and distribute / refund continue to work normally, forever.

What this means for limit orders resting on the book

This is the scenario to understand clearly. A limit order can rest on the CLOB for as long as needed. The 72-hour timeout starts when the fee is pulled, not when the order fills. Here is exactly what happens at each boundary:
SituationBehavior
Order fills within 72hOperator calls distribute — funds flow to treasury and affiliate normally. Nothing unusual.
Order cancelled within 72hOperator calls refund — full fee returns to the user. Nothing unusual.
Order still resting at hour 72, user does nothingEscrow sits untouched. When the order eventually fills — minute 73, day 5, month 2 — operator calls distribute and funds flow normally. Everyone is paid.
Order still resting at hour 72, user calls claimRefundUser gets their fee back. If the order then fills later, the fill is effectively fee-free. The builder gives up the fee on that one order; nothing else breaks.
In practice, virtually no user will invoke claimRefund while their own order is still trying to fill. They’d have no reason to — they placed the order because they wanted the trade. The timeout exists as a safety valve for the case where the operator is unable to settle (outage, pause, shutdown), not as a routine workflow step.

Practical guidance for builders

  • If you charge fees on orders expected to fill quickly (aggressive limits near midprice), the 72h window is a non-issue.
  • If you charge fees on long-dated limits that commonly rest for days, you can either accept that a fully-informed user has an escape hatch, or skip the fee on those specific orders by passing feeBps: 0.
  • If you really want to keep fee coverage alive on a long-resting order, the operator can proactively refund before hour 72 and re-pull a fresh signature from the user with a new nonce. This is a manual flow and most builders won’t need it.
Funds are never permanently locked, never routed to a third party, and never surprised away from either the user or the builder during the normal fill path.

Security

V1 has been through three independent security audits and 57 Foundry tests covering unit, fuzz, and adversarial scenarios. V2 is a strict two-change diff from V1 (collateral token address + EIP-712 domain strings). Every other line of the contract is byte-identical to the audited V1 source. Verification against the deployed V2 contract on Polygon mainnet: 8/8 fork tests pass (pullFee, distribute 30/70 split, refund, 72h claimRefund, and every revert path — expired deadline, wrong nonce, tampered signature). Tests impersonate a real on-chain pUSD holder to fund the payer. Both versions enforce the same invariants:
  • Signature malleability — rejected (secp256k1 s-value upper bound check)
  • Replay attacks — prevented by per-signer sequential nonces
  • Affiliate share overflow — capped at 10000 bps (100%)
  • Self-referential payer — blocked (payer cannot be the escrow contract)
  • ecrecover(0) — rejected explicitly
  • Operator compromise — operator can be revoked by Safe owner instantly
  • Stuck funds — 72-hour user self-refund + owner emergency withdraw

SDK Integration

Enable fee collection with a single config option. Everything else is automatic.

Basic Setup

import { PolyNodeTrader } from 'polynode-sdk';

const trader = new PolyNodeTrader({
  polynodeKey: 'pn_live_...',
  feeConfig: {
    feeBps: 50,                          // 0.5% fee on every order
    affiliate: '0xYourWallet...',        // REQUIRED: your wallet that receives the fee
  },
});
The affiliate field is required when feeBps > 0. This is the wallet where your fees are sent. By default, you keep 100% of the fee. If you want to share a portion with the polynode treasury, set affiliateShareBps to less than 10000:
feeConfig: {
  feeBps: 50,
  affiliate: '0xYourWallet...',
  affiliateShareBps: 7000,              // optional: keep 70%, share 30% with polynode
}
When feeConfig is set with feeBps > 0, every order automatically:
  1. Calculates the fee from the order’s price and size
  2. Signs an EIP-712 authorization (free, off-chain)
  3. Pulls the fee into escrow before the order reaches the CLOB
  4. Refunds the fee automatically if the order is cancelled

Fee Calculation

The fee is calculated as:
feeAmount = floor(price × size × 1e6 × feeBps / 10000)
For example, BUY 10 shares at $0.55 with feeBps: 50:
floor(0.55 × 10 × 1e6 × 50 / 10000) = 27500 raw USDC = $0.0275

Placing an Order with Fees

// Initialize wallet (one-time setup — handles Safe deploy + all 7 approvals including escrow)
const status = await trader.ensureReady(privateKey);
console.log('Wallet ready:', status.funderAddress);
// Send USDC.e to status.funderAddress before placing orders

// Find a market
const results = await fetch('https://api.polynode.dev/v1/search?q=hungary', {
  headers: { 'x-api-key': 'pn_live_...' },
}).then(r => r.json());

const market = results.results[0];
console.log(market.question, '→ token:', market.token_ids[0]);

// Check the orderbook
const book = await fetch(`https://api.polynode.dev/v1/orderbook/${market.token_ids[0]}`, {
  headers: { 'x-api-key': 'pn_live_...' },
}).then(r => r.json());

console.log('Best bid:', book.bids[0]?.price, 'Best ask:', book.asks[0]?.price);

// Place order — fee escrow happens automatically
const result = await trader.order({
  tokenId: market.token_ids[0],
  side: 'BUY',
  price: 0.02,    // well below best ask, will rest on book
  size: 10,
});

console.log('Order ID:', result.orderId);
console.log('Fee TX:', result.feeEscrowTxHash);   // on-chain pullFee transaction
console.log('Fee:', result.feeAmount, 'USDC');     // fee amount charged
The response includes feeEscrowTxHash (the on-chain transaction that moved USDC into escrow) and feeAmount (how much was charged).

Cancelling (Auto-Refund)

if (result.orderId) {
  const cancel = await trader.cancelOrder(result.orderId);
  console.log('Cancelled:', cancel.canceled);
}
// Fee is automatically refunded on-chain — no extra call needed
When you cancel an order that had a fee, the refund happens inline during the cancel. The USDC returns to your Safe wallet in the same request. No polling, no background jobs needed. cancelAll() also triggers refunds for every cancelled order that had a fee.

Per-Order Fee Override

Override the global feeConfig on individual orders:
// This order uses a different fee rate and affiliate
const result = await trader.order({
  tokenId: '...',
  side: 'BUY',
  price: 0.55,
  size: 100,
  feeConfig: {
    feeBps: 100,                           // 1% for this order
    affiliate: '0xSpecialPartner...',
    affiliateShareBps: 5000,               // 50/50 split
  },
});

Opting Out

Set feeBps: 0 or omit feeConfig entirely. The SDK skips the escrow and the order goes straight to the CLOB with zero overhead. Existing behavior is completely unchanged.
// No fee — identical to pre-escrow behavior
const trader = new PolyNodeTrader({ polynodeKey: 'pn_live_...' });

What the SDK Does Under the Hood

When you call trader.order() with feeBps > 0:
SDK                          Cosigner                     FeeEscrow        CLOB
 │                              │                            │               │
 │  1. Calculate fee            │                            │               │
 │  2. Generate escrowOrderId   │                            │               │
 │  3. Sign EIP-712 FeeAuth     │                            │               │
 │                              │                            │               │
 │── POST /submit (w/ fee) ───>│                            │               │
 │                              │── pullFee(feeAuth) ──────>│               │
 │                              │<── tx confirmed ──────────│               │
 │                              │── forward order ─────────────────────────>│
 │                              │<── orderId ──────────────────────────────│
 │<── { orderId, feeTxHash } ──│                            │               │
 │                              │                            │               │
 │── DELETE /submit (cancel) ─>│                            │               │
 │                              │── forward cancel ────────────────────────>│
 │                              │<── canceled ─────────────────────────────│
 │                              │── refund() ──────────────>│               │
 │                              │<── tx confirmed ──────────│               │
 │<── { canceled } ────────────│                            │               │

Approvals

The escrow contract requires one USDC approval. This is automatically included in the approval batch during ensureReady() — the 7th approval alongside the 6 Polymarket approvals. For Safe wallets, it’s batched into the same multicall transaction (no extra gas). For EOA wallets, it’s one additional approval TX (~$0.001 MATIC).