Skip to main content
This guide takes a platform from Connect wallet through the first actual user-owned order. It shows what belongs in the browser, what stays on your backend, how to retain credentials, and how to support more than one user without mixing their state.
Use TypeScript polynode-sdk >= 0.14.2, Python polynode >= 0.14.1, or Rust polynode >= 0.17.1 with its trading feature. Wallet authorization requires an eligible paid Polynode plan and a valid pn_live_... API key. It is not a separate add-on and does not require another service URL.
User-owned mode removes your platform’s shared builder relayer allowance from these operations by using credentials bound to each signing wallet. Orders carry zero builder attribution. This is not a promise of unlimited order throughput: wallet balances, market rules, order API limits, and ordinary rate limits still apply.

What the user sees

1

Connect the wallet on Polygon

Your application reads the connected EOA and refuses to continue on the wrong chain.
2

Enable user-owned execution

The wallet signs one short-lived ownership message. Your platform never receives a private key or seed phrase.
3

Finish wallet readiness

On first use, the wallet may show additional prompts to create order API credentials and establish the selected trading account’s permissions.
4

Confirm the order

Your UI displays the SDK’s exact order preview. The wallet signs the exact EIP-712 request produced for that order.
5

Show the result

Your application displays the accepted order ID or a clear failure. A timeout is reconciled as an ambiguous result, never retried blindly.
The two signatures have different purposes. The first authorizes wallet-owned execution. The second authorizes one exact order. Neither gives your platform the wallet’s private key.

Choose a custody model

The first two models use the same wallet authorization. You can begin with the browser-memory model, export its versioned credential bundle once to an encrypted vault, and then use the recommended browser-sign/backend-submit model for later orders.

Endpoints in your application

The route names below are examples implemented by your own backend. The browser never needs a second Polynode URL and never receives the platform API key. Use equivalent names in your framework if preferred. The security properties, field boundaries, authenticated user binding, and one-time semantics are the contract that matters.

One-time store contract

Your Redis or database wrapper must expose two operations, not a general get() followed by delete():
  • putOnce(id, owner, wallet, payload, expiresAt) succeeds only when the ID is absent.
  • takeOnceForOwner(id, owner, wallet) atomically checks ownership and expiry, deletes the row, and returns its payload exactly once.
A PostgreSQL implementation can make the security boundary explicit:
Use a unique constraint on (kind, opaque_id) for insertion. Return the same generic not-found response for a wrong owner, wrong wallet, expired ID, and already-consumed ID. A Redis implementation needs one Lua script or an equivalent transactional primitive that performs all comparisons and deletion atomically; GET followed by application checks and DEL is not sufficient.
Do not silently fall back from builder mode to user-owned mode when a shared builder allowance is exhausted. Ask the user to opt in, complete authorization, and switch modes before starting an operation. A partially completed builder request must not be replayed as a user-owned request.

Credential boundary

The wallet-owned relayer credential cannot sign an order or transfer funds by itself, but it can authorize gasless activity for its owner. Treat it as a secret. Order API credentials can authenticate trading requests and must be protected to the same standard.

Install only this flow’s dependencies

Every injected-wallet integration uses the browser entry point, even when the prepare and submit backend is written in Python or Rust:
Browser
That is the complete browser install. Do not add the optional builder-relayer, builder-signing, SQLite, or server-wallet packages for this user-owned flow. They belong to separate server-side integrations and are not imported by polynode-sdk/trading/browser. The browser entry point is ESM-only. Import it from an ESM browser build such as Vite or Next.js; do not load it with require(). On a bundled Node backend, leave polynode-sdk as a runtime dependency instead of asking the bundler to crawl integrations your application did not install. For esbuild, either externalize all packages or just the SDK:
Backend build
The deployment must retain its production node_modules. The equivalent Next.js setting is serverExternalPackages: ['polynode-sdk']; with esbuild, packages: 'external' is also valid. This does not add optional builder or server-wallet packages to the user-owned installation. Install the backend SDK for the language that owns your routes:

Before you start

  • Create the platform trader with executionMode: "user_owned", ExecutionMode.USER_OWNED, or ExecutionMode::UserOwned.
  • Use V2. The browser-signing bridge rejects legacy V1 before it fetches market data or asks for a signature.
  • Keep one encrypted credential record and one trader context per controlling wallet address.
  • Use Polygon chain ID 137 (0x89) and fail closed if the wallet changes account or chain.
  • Fund the returned funderAddress before a BUY. Readiness creates no balance and never moves collateral automatically. Keep funding as a distinct, wallet-confirmed step in your application.
  • Do not configure builder credentials, a nonzero builder code, or a positive Polynode fee. User-owned mode rejects all three.
The specialized BrowserUserOwnedSession defaults a new session to the current deposit-wallet account type. This is deliberate and differs from the general server-signer trader’s legacy fallback for an address with no deployed wallet. Pass signatureType: SignatureType.EOA to choose an explicit EOA; its initial approvals are on-chain transactions paid by that EOA. Existing Safe users can keep their already-bound Safe identity in the backend-vault flow. The browser session does not silently replace that Safe. Legacy proxy/Magic accounts are not supported in user-owned mode. Import the explicit account-type enum with import { SignatureType } from 'polynode-sdk'.

1. Connect and bind the wallet

Read the current account and chain before asking your backend to create any state. You may offer a deliberate chain-switch button; do not sign while the provider reports another chain.
Frontend
Record this as a pending address for the authenticated application user, then finalize that binding only after the ownership signature succeeds. On every later authorization or order route, compare the request’s wallet with that server-side binding instead of trusting an address sent in JSON.

2. Start wallet authorization on the backend

The backend calls the SDK with its own Polynode API key, keeps the complete challenge in a short-lived one-time store, and returns only the fields needed for the wallet prompt. The SDK’s trader uses the normal Polynode trading service configuration; there is no second customer URL to discover.
Backend
pendingAuthorizations represents your own server-side store. In a shared store, creation must fail if the identifier already exists. Apply a TTL equal to the challenge expiry and bind the row to both the authenticated user and expected wallet. The SDK trader may be request-scoped. A completion worker can create a new user-owned trader with the same backend configuration and use the stored challenge; do not keep a worker or process alive merely to span the wallet prompt. Close each trader after its backend job finishes, including when an SDK call throws or the client disconnects. Put the complete route body inside try/finally in TypeScript or Python. In Rust, keep the trader in the request scope so Drop runs on every ? return; an explicit close() on the success path is still fine. The fragments below omit repeated route boilerplate, but this exception-safe lifetime is required for every authorization, vault, prepare, and submit handler. Authorization-challenge expiry is a canonical RFC 3339 UTC string with millisecond precision in all three SDKs, for example 2026-08-18T12:34:56.000Z. Parse it once for your store’s TTL, but retain the original complete challenge unchanged for SDK completion. Prepared-order expiresAt is instead an integer Unix timestamp in seconds; do not apply the challenge parser to it.

3. Sign the ownership message

The browser checks that the response still belongs to the connected account, then requests a standard personal-message signature.
Frontend
Do not let the frontend change the message. The SDK validates the complete challenge shape, wallet, lifetime, chain, and signature format during completion.

4. Complete authorization on the backend

Atomically remove the saved challenge before completion, but make ownership part of that same operation. Match the ID, authenticated application user, and server-bound wallet in one DELETE ... WHERE ... RETURNING, transaction, or equivalent script. A mismatched caller must receive no row and must not delete another user’s challenge. Then give the SDK only the original challenge and browser signature.
Backend
The exact JSON response shape is {"userRelayerCredentials":{"key":"...","address":"0x..."}}. Set Cache-Control: no-store on that response and exclude its body from access logs. At this point, choose the browser-memory path below or save the credential in the backend-vault path. Never include the Polynode API key in either response.

Option A: browser-memory session

This is the shortest path to an actual order. It is TypeScript-only in the browser, but the authorization backend above may use any of the three SDKs. Return the wallet-owned credential once over an authenticated same-origin HTTPS response with Cache-Control: no-store. Do not let an access log, error tracker, analytics tool, service worker, or response cache capture the body.
Frontend
Version 0.14.2 accepts viem’s standard EIP1193Provider directly; no provider cast or adapter is required. open() still checks at runtime that the injected provider supplies request, on, and removeListener. open() forces user-owned V2, direct transport, and memory-only storage. It accepts no Polynode key, builder credential, builder code, positive fee, proxy setting, or persistent storage. It validates Polygon and the connected account, establishes the default trading account when needed, confirms permissions, and creates or derives that wallet’s order API credentials. Readiness does not create collateral. Before a BUY, show the funder address and balances in your funding UI. The user must first acquire or transfer USDC.e to that exact address through your normal wallet/onramp UX. Once it is there, the SDK performs the wallet-confirmed USDC.e → pUSD conversion for either an EOA or deposit-wallet session:
Frontend
wrapToPolyUsd() never obtains assets, chooses an amount, or runs silently. It uses raw six-decimal units, checks USDC.e first, asks the connected wallet to approve and confirm the required transaction or gasless batch, and waits for confirmation. The application-defined UI functions above display the chosen amount and direct an underfunded user to your existing deposit/bridge flow. Do not prepare the order until the final pUSD balance check passes. Once funded, place the order. The session builds an exact V2 request with zero builder attribution, asks the connected wallet for its EIP-712 signature, checks the signer again, and submits it.
Frontend
The confirmation callback receives the exact 11-field, credential-free order preview. Returning false or throwing stops the flow before signing or submission. beforeSubmit receives a frozen, credential-free BrowserBeforeSubmitOrder containing the canonical exchange orderHash and that preview. The SDK awaits it after finalizing the signature but before the submission request; if durable storage fails, it sends no order. Bind this record to the authenticated application user and expected funder. A browser-supplied record is useful for that user’s reconciliation but must never authorize another action or another user’s read. After each callback, the SDK rechecks the account and chain immediately before continuing. It also checks BUY balance before the signature. It closes the session on pagehide, accountsChanged, or chainChanged. Also close it explicitly on logout or when your UI no longer needs it:
JavaScript strings cannot be cryptographically zeroized. close() removes the SDK’s references and clears its in-memory maps, but an XSS payload, malicious extension, browser debugger, or prior application copy can still observe live credentials. Use a strict CSP, trusted dependencies, no third-party analytics on this route, and the backend-vault model for long-running production use.

Option B: encrypted backend vault

The recommended production model leaves long-lived credentials on your backend. The browser keeps them only long enough to transfer one versioned bundle to your own vault, then closes the session. Later orders send only a short-lived signing request to the browser. Run BrowserUserOwnedSession.open(...) from Option A once to complete wallet readiness and create the bound credential set. Choosing the vault model means exporting that session immediately after onboarding, not skipping onboarding.

Export once after onboarding

Frontend
The sensitive bundle has one stable schema across all three SDKs:
signatureType is 0 for an EOA, 2 for an existing EOA-controlled Safe, or 3 for the current deposit-wallet account. Value 1 is rejected. Your backend must parse the body with a size limit, validate it through the SDK, encrypt it under the authenticated user’s wallet record, and return no copy of the credential. Key vault records by a canonical lowercase wallet address and enforce a unique owner. Never use a browser-supplied user ID or funder address as the database authority.

Validate and load the bundle

Backend
The importers reject an unknown version, extra fields, unsupported wallet type, wrong credential owner, or funder that does not belong to the controlling EOA. They load credentials only into memory and do not install a signer. The vault record itself still contains secrets. Encrypt it with a managed key, restrict decryption to the order worker, audit access without recording values, and support per-wallet revocation and replacement.

Prepare one exact order

For every order, decrypt only that wallet’s bundle into a dedicated in-memory trader. Ask the SDK to prepare the order, then put the server-only state in a one-time store. Return only signingRequest to the browser.
Backend
exportPreparedUserOwnedOrderState() invalidates the original in-process object, which prevents the local copy and shared-store copy from both being submitted.
The server-state encodings contain no Polynode key, wallet-owned relayer key, or order API credential. Each SDK authenticates the complete encoding with a domain-separated tag keyed by the backend-held Polynode key and verifies the tag again before submission. Every worker handling one pending order must use the same key. The state is still backend-only: it contains the exact order intent and security bindings and must never be accepted from a browser. Use a unique put-if-absent, apply the signing request’s expiry as the TTL, and delete abandoned records. Rotating the platform key intentionally invalidates outstanding prepared orders; have users prepare them again. The prepared object’s orderHash / order_hash / order_hash() is the canonical standard exchange Order EIP-712 digest. It is intentionally not the deposit wallet’s outer TypedDataSign digest. It is non-secret and does not change the opaque server-state schema, but read it from the integrity-bound prepared object before export and persist it with the attempt. Never accept a replacement hash from the order-signature response. For a BUY, each SDK compares the exact maker amount with the funder’s current balance before it returns a signing request. An insufficient balance therefore fails before the wallet sees an order-signature prompt. The SDK never funds the account or changes the order after preparation.

Send only the shared signing request

All three SDKs return the same version-1 browser shape:
primaryType is determined by the selected wallet account type. Do not assume or replace it: sign the entire returned typedData object exactly. Large typed-data integers are decimal strings. Do not convert token IDs or raw amounts to JavaScript number; doing so can change the signed value. The preview’s price and size are the canonical tick- and size-rounded values used by the order. makerAmount and takerAmount are raw six-decimal-unit strings; maker is the trading/funder account, while signer is the account the order signature is bound to.

Verify, display, and sign in the browser

The browser must check the schema version, expiry, chain, and currently active account before it displays the preview. The preview is for confirmation; sign the exact typedData object, not a new object reconstructed from form inputs.
Frontend
Do not send typedData, the order preview, token ID, price, or size back on the submit route. The backend uses only its prepared state plus the browser’s requestId, address, and signature.

Atomically take and submit on the backend

Use GETDEL, a database DELETE ... RETURNING, or an equivalent transaction. The take must be atomic across workers and conditional on the authenticated application user plus server-bound wallet. A leaked request ID must not let a different caller delete the row. Remove the prepared state before the SDK makes the authenticated network request so a timeout cannot make the same signature reusable.
Backend
Before submission, the SDK revalidates the full typed-data and order-state pair, request version and expiry, zero builder attribution, active wallet and funder, wallet-owned credential owner, canonical low-S signature, and recovered signer. It builds authenticated order data only after those checks pass.

Classify and reconcile a timeout

The trading APIs do not expose a per-order timeout setting. Do not put an aggressive cancellation timeout around the SDK call and then assume that a cancelled HTTP request prevented submission. Use this conservative classification:
  • An error before the language’s SDK submit method is invoked is a definite local failure. The prepared row is already one-time; let the user prepare again after correcting it.
  • A completed SDK result that explicitly rejects the order is definite. Show that result and prepare a new intent only after the user changes or reconfirms it.
  • A transport timeout, connection reset, cancelled request, worker crash, or unclassified exception after invoking the submit method is ambiguous. When in doubt, classify it as ambiguous.
Before invoking submission, persist a non-secret attempt record separately from the consumed state: the SDK-derived canonical orderHash, requestId, authenticated user, wallet/funder, the credential-free order preview, and submission time. Set its status to submitting. The durable write must finish before the SDK is allowed to start the submission request. Do not put the signature, credential bundle, or reusable prepared state in that record. This identity requirement is not optional. Token, direction, price, size, and time can describe two different orders from the same wallet. They may validate an exact-hash match as defense-in-depth, but they must never select a candidate on their own. For an ambiguous attempt:
  1. Load the wallet’s authoritative open orders with trader.getOpenOrders({ assetId: tokenId }) (TypeScript), await trader.get_open_orders(asset_id=token_id) (Python), or trader.get_open_orders(None).await? and filter the returned token locally (Rust). Match only an order whose id, case-insensitively, equals the persisted orderHash. If it exists, the exact order was accepted and is still open or partially open.
  2. A fully filled order may no longer be open. Query V2 fill history for attempt.order.maker, which is the signing preview’s order.maker and the session’s funderAddress. Do not query the controlling EOA for a Safe or deposit-wallet order merely because it signed the request. Keep only rows whose order_hash, case-insensitively, equals the persisted orderHash, then group those exact rows to summarize partial fills.
  3. Validate the exact-hash rows against the expected maker, token, BUY/SELL direction, raw amount bounds, and integer-rounded limit described on the fill-history page. A mismatch is a data-integrity error, not permission to search for a different hash.
  4. Repeat the exact open-order and fill reads after a short bounded delay to allow indexing. If the hash remains absent, mark the attempt needs_reconciliation and surface it for operator/user review. An accepted order could have been cancelled or expired before the read; absence is not proof that submission failed.
Local SDK order history can assist, but it is not sufficient proof after a worker crash. Never restore, resubmit, or automatically recreate the consumed state merely because the first reconciliation read found nothing. For the browser-memory model, beforeSubmit is the only safe place to create the durable attempt: it exposes the exact hash and the SDK awaits your storage receipt before transport. Version 0.14.2 also exposes the authenticated open-order read as await session.getOpenOrders({ assetId: attempt.order.tokenId }). Keep the attempt outside the session so it survives a tab refresh; use the same exact-hash fill comparison and ambiguity rules. The hash is a correlation key, not an idempotency key, and the SDK never resubmits automatically.

Option C: managed service signer

If your application already uses an HSM, MPC wallet, Privy server wallet, or another controlled signer, keep using the normal combined SDK flow. Configure a user-owned trader, run ensureReady/ensure_ready, and call order. The signer must support both personal-message and typed-data signatures. This model is appropriate for automated strategies. Do not convert an injected wallet user to a service signer merely to avoid implementing the split-signing flow, and never ask a user to paste a private key into your application.

Secure route requirements

Apply these controls to the authorization, vault, prepare, and submit routes:
  1. Require HTTPS and an authenticated same-origin application session.
  2. Bind the application user to one expected wallet; never trust a body-supplied user ID.
  3. Protect cookie-authenticated writes with CSRF defenses and restrictive SameSite cookies.
  4. Rate-limit by application user, wallet, IP risk signal, and operation.
  5. Set request-body limits and reject unexpected JSON fields.
  6. Use Cache-Control: no-store on every response that contains a challenge, credential, bundle, prepared state acknowledgment, or signature result.
  7. Redact request and response bodies from access logs, traces, replay tools, exception reports, support captures, and analytics.
  8. Disable service-worker caching for these paths.
  9. Use a restrictive CSP and audited dependencies on any page that temporarily holds credentials. Do not run advertising or third-party analytics there.
  10. Encrypt vault values at rest, restrict decryption to the order worker, and audit access by wallet ID without logging values.
  11. Use atomic put/take semantics and TTLs for challenges and prepared orders.
  12. Close the SDK trader after each job so active signers and in-memory credentials are released.
For browser-memory mode, credential delivery should be a one-time response. Do not place it in a URL, query string, redirect, HTML, local storage, session storage, IndexedDB, cookie, global state store, or hydration payload.

Access and common errors

Production acceptance checklist

  • A new user can connect on Polygon, authorize, complete readiness, see the funder address, fund it, review an order, sign it, and receive an order result.
  • A returning user can load the encrypted record without another ownership signature unless reauthorization is required.
  • Two users cannot load one another’s vault record, challenge, prepared state, trader, or result.
  • Account and chain changes close browser sessions and invalidate the current UI flow.
  • The browser never sees the Polynode API key or prepared server state.
  • Backend logs and telemetry contain no credential bundle, API secret, passphrase, full challenge, or signature.
  • Builder credentials, builder authentication, builder code, and positive Polynode fees are absent from the user-owned path.
  • Challenges and prepared orders are one-time, short-lived, and atomically consumed across all workers with the authenticated user and wallet included in the same conditional take.
  • The canonical exchange order hash is durably stored before every submit request and exact-matched during open-order and fill reconciliation.
  • A timeout cannot cause an automatic duplicate order.
  • Your UI says that user-owned mode removes the shared builder allowance; it does not advertise all activity as universally unlimited.
For mode behavior and gasless position operations, continue with User-owned execution. For general order inputs, funding, cancellation, and result handling, see Trading and fees.