Skip to main content

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: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 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 in every matching 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. 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

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)

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

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).
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: polynode-sdk@0.9.2 (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.

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:
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.
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.
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.
Verified on Polygon — order 0x71f943... placed at 45c, cancelled, fee refunded.

Market Sell (fills immediately)

Verified on Polygon — matched, fee distributed.

Batch Cancel

Multiple resting orders can be cancelled at once. Each gets its own refund.
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.
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.
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.
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: 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

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:
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:
For example, BUY 10 shares at $0.55 with feeBps: 50:

Placing an Order with Fees

The response includes feeEscrowTxHash (the on-chain transaction that moved USDC into escrow) and feeAmount (how much was charged).

Cancelling (Auto-Refund)

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:

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.

What the SDK Does Under the Hood

When you call trader.order() with feeBps > 0:

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).