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.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.
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 generalget() 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.
(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.
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
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
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:
- TypeScript
- Python
- Rust
Before you start
- Create the platform trader with
executionMode: "user_owned",ExecutionMode.USER_OWNED, orExecutionMode::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
funderAddressbefore 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.
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
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.- TypeScript
- Python
- Rust
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
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 oneDELETE ... 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.
- TypeScript
- Python
- Rust
Backend
{"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 withCache-Control: no-store. Do not let an access log, error tracker,
analytics tool, service worker, or response cache capture the body.
Frontend
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
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:
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. RunBrowserUserOwnedSession.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
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
- TypeScript
- Python
- Rust
Backend
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 onlysigningRequest to the browser.
- TypeScript
- Python
- Rust
Backend
exportPreparedUserOwnedOrderState() invalidates the original in-process
object, which prevents the local copy and shared-store copy from both being
submitted.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 exacttypedData object, not a new object reconstructed from form inputs.
Frontend
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
UseGETDEL, 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.
- TypeScript
- Python
- Rust
Backend
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.
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:
- Load the wallet’s authoritative open orders with
trader.getOpenOrders({ assetId: tokenId })(TypeScript),await trader.get_open_orders(asset_id=token_id)(Python), ortrader.get_open_orders(None).await?and filter the returned token locally (Rust). Match only an order whoseid, case-insensitively, equals the persistedorderHash. If it exists, the exact order was accepted and is still open or partially open. - A fully filled order may no longer be open. Query
V2 fill history for
attempt.order.maker, which is the signing preview’sorder.makerand the session’sfunderAddress. Do not query the controlling EOA for a Safe or deposit-wallet order merely because it signed the request. Keep only rows whoseorder_hash, case-insensitively, equals the persistedorderHash, then group those exact rows to summarize partial fills. - 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.
- 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_reconciliationand 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.
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, runensureReady/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:- Require HTTPS and an authenticated same-origin application session.
- Bind the application user to one expected wallet; never trust a body-supplied user ID.
- Protect cookie-authenticated writes with CSRF defenses and restrictive
SameSitecookies. - Rate-limit by application user, wallet, IP risk signal, and operation.
- Set request-body limits and reject unexpected JSON fields.
- Use
Cache-Control: no-storeon every response that contains a challenge, credential, bundle, prepared state acknowledgment, or signature result. - Redact request and response bodies from access logs, traces, replay tools, exception reports, support captures, and analytics.
- Disable service-worker caching for these paths.
- Use a restrictive CSP and audited dependencies on any page that temporarily holds credentials. Do not run advertising or third-party analytics there.
- Encrypt vault values at rest, restrict decryption to the order worker, and audit access by wallet ID without logging values.
- Use atomic put/take semantics and TTLs for challenges and prepared orders.
- Close the SDK trader after each job so active signers and in-memory credentials are released.
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.

