What chains and assets are supported?
Zipper is Polyester's deposit/withdraw bridge. Its public config lists supported chains, unified assets, and per-chain routes, including network fees and minimums:
const config = await client.zipper.getDepositWithdrawConfig();Same data through the catalog's zipper reader, with lookups and conversions:
await client.catalog.ensureReady();
const chains = client.catalog.zipper.listChains();
const eth = client.catalog.zipper.requireAssetBySymbol("ETH");
const route = client.catalog.zipper.requireAssetChain("ETH", "ethereum-sepolia");Deposits
Create (or fetch) a deposit address for an account on a chain, then send funds to it:
const address = await client.deposit.createAddress({ chainId });
const addresses = await client.deposit.listAddresses();Addresses are per-account. Pass account for a subaccount's address.
Tracking with lifecycle flows
Cross-chain ops (deposits, withdrawals) move through multi-step lifecycle flows. The lifecycle service reads and streams that state:
const { flows } = await client.lifecycle.listFlows({ limit: 50 });
const flow = await client.lifecycle.getFlow({ flowId });
// find the flow for a transaction hash
const byTx = await client.lifecycle.listFlowsByTx({ txHash, lookupKind: "any" });
// live progress
const unsubscribe = client.lifecycle.subscribeFlowDetail({
flowId,
onEvent: (detail) => updateProgress(detail),
});subscribeOpenFlows streams summaries of all open flows for an account. A summary's isDelayed flags an open flow stalled well past its current step's expected time.
Ledger transfers
Every balance movement is a ledger transfer. List and stream them:
const { transfers, nextPageToken } = await client.transfers.list({ limit: 50 });
const unsubscribe = client.transfers.subscribe({
accountId,
onEvent: (transfer) => console.log(transfer),
});Both legs of a transfer share its transferId. Key stored legs by transferId, accountCode, and isDebit: a leg the stream delivers again replaces the earlier copy.
Internal transfers
Move funds between Polyester accounts (root, subaccount, or another user) without touching a chain:
const result = await client.internalTransfers.create({
destination: { type: "subaccount", subaccountId },
assetId: 2,
quantity: "125.50",
idempotencyKey: crypto.randomUUID(),
});Resolve destinations with client.accounts.resolve(...). Saved ones come from the address book
(client.addressBook.listTransferDestinations()). Requests are idempotent.
Withdrawals from Trading
Withdrawals out of Trading are signed intents. Two destinations:
import type { TradingWithdrawWalletSigner } from "@polyester/sdk";
const walletSigner: TradingWithdrawWalletSigner = {
signerWallet: owner.address,
accountId: me.accountId,
signMessage: (message) => owner.signMessage({ message }),
};
// Trading to Funding (stays on Polyester)
await client.tradingWithdraws.createToFunding({
assetId: 2,
quantity: "500.00",
idempotencyKey: crypto.randomUUID(),
walletSigner,
});
// Trading to an external chain
await client.tradingWithdraws.createToExternalChain({
assetId: 2,
quantity: "500.00",
destinationChainId: 1,
destinationAddress: address,
idempotencyKey: crypto.randomUUID(),
walletSigner,
});Each intent has a short deadline (5 minutes by default; pass deadline to sign a different
expiry), a nonce, and an idempotency key. walletSigner signs the SDK's canonical EIP-191 text
message with personal_sign; alternatively, provide a precomputed payloadSignature for API-key
infrastructure.
When submission can stall, for example on an MFA step-up, sign once with prepareToFunding or prepareToExternalChain and call submit again after the step-up:
import { isFreshStepUpRequiredError } from "@polyester/sdk";
const prepared = await client.tradingWithdraws.prepareToExternalChain({
assetId: 2,
quantity: "500.00",
destinationChainId: 1,
destinationAddress: address,
idempotencyKey: crypto.randomUUID(),
walletSigner,
});
try {
await prepared.submit();
} catch (error) {
if (!isFreshStepUpRequiredError(error)) throw error;
// Satisfy the step-up, then submit the same prepared withdrawal again.
await prepared.submit({ stepUpToken });
}prepared.isExpired() turns true a minute before prepared.deadline. From then on, a
wallet-signed withdrawal signs a fresh deadline and nonce with the same idempotency key on submit. A payload-signed one can't be signed again: once the deadline passes, submit throws WithdrawDeadlineExpiredError (a ValidationError subclass) without sending, so prepare it again
with the same input and idempotency key.
An external-chain quantity with more decimal places than the destination token supports fails
with withdraw error code AMOUNT_EXCEEDS_ROUTE_PRECISION before any funds are reserved. Round it
down to the token's decimals before signing.
Before you ask anyone to sign an external-chain withdrawal, check the destination:
const check = await client.tradingWithdraws.validateDestination({
destinationChainId: 1,
destinationAddress: address,
});
if (!check.valid) throw new Error(check.message); // code says why: token contract, denylisted, ...This catches token contracts, unsupported chains, denylisted addresses, and Polyester smart accounts (which want an internal transfer instead) before a signature exists, and it returns the canonical form of the address to submit. See the withdrawals reference for every code.
When a withdrawal fails after admission, the lifecycle reason names the cause: trading_withdraw_policy_denied, trading_withdraw_contract_reverted, or trading_withdraw_execution_failed.
Scheduled auto-withdraw
client.autoWithdraw pays an account's balances out on a UTC schedule to addresses on its
external withdraw whitelist. Read the rule, then replace it with the version you read:
const current = await client.autoWithdraw.getRule();
await client.autoWithdraw.setRule(
{
expectedVersion: current?.version ?? 0,
schedule: { cadence: "daily", minuteOfDay: 0 },
destinations: [
{
assetId: 2,
destinationChainId: 1,
destinationAddress: address,
minAmount: "100",
maxAmount: "5000",
},
],
},
{ stepUpToken }
);
const { runs } = await client.autoWithdraw.listRuns();setRule and disableRule need a fresh stepUpToken, and a stale expectedVersion throws RevisionConflictError. See Auto-withdraw.
client.guardSigner to co-sign protected actions. signProtectedAction / batchSignProtectedActions produce approvals. createWallet / rotateWallet / exportWallet manage the
backend guard wallet.On-chain actions with the smart account
For a real chain transaction from the user's smart account (not an API request), use @polyester/sdk/smart-account. It wires viem + account abstraction against the
environment's bundler and paymaster:
import {
createPolyesterSmartAccount,
createPolyesterSmartAccountClient,
sendPolyesterUserOperation,
waitForPolyesterUserOperationReceipt,
warmPolyesterSmartAccountClient,
} from "@polyester/sdk/smart-account";
const account = await createPolyesterSmartAccount({ environment, owner });
const smartAccountClient = createPolyesterSmartAccountClient(account, { environment });
await warmPolyesterSmartAccountClient(smartAccountClient);
const onPhase = (phase: string, ms: number) => console.log(phase, ms);
const hash = await sendPolyesterUserOperation(
smartAccountClient,
{ calls: [{ to: destinationAddress, value: 0n, data: "0x" }] },
{ onWalletSignatureRequested: () => setStatus("confirm in wallet"), onPhase }
);sendPolyesterUserOperation prepares the operation in one pass, then asks the wallet to sign.
The returned hash identifies the submitted operation; it is not a receipt.
Gas estimation errors throw; the SDK does not silently resend. onWalletSignatureRequested fires
on the third-argument options object, immediately before the sign prompt.
Bundler requests time out after 30 seconds. Two failures after signing carry the operation's
deterministic userOpHash, because the bundler may already hold it. Wait on that hash with waitForPolyesterUserOperationReceipt instead of signing again, since a new operation could
execute a second time:
| Error | Code | When |
|---|---|---|
UserOperationSendTimeoutError | USER_OPERATION_SEND_TIMEOUT | eth_sendUserOperation timed out. The outcome is unknown, so it is not retryable. |
UserOperationAlreadyKnownError | USER_OPERATION_ALREADY_KNOWN | The bundler answered "already known": the identical operation is pending. An AlreadyExistsError subclass. |
import {
UserOperationAlreadyKnownError,
UserOperationSendTimeoutError,
} from "@polyester/sdk/smart-account";
let hash: `0x${string}`;
try {
hash = await sendPolyesterUserOperation(smartAccountClient, {
calls: [{ to: destinationAddress, value: 0n, data: "0x" }],
});
} catch (error) {
if (
error instanceof UserOperationSendTimeoutError ||
error instanceof UserOperationAlreadyKnownError
) {
hash = error.userOpHash;
} else {
throw error;
}
}warmPolyesterSmartAccountClient primes the gas-price cache and opens RPC connections ahead of
submission. It never throws and does not cache the nonce. Gas prices cache
for 60 seconds by default (options.gasPriceCacheTtlMs). Repeated createPolyesterSmartAccountClient calls with the same account instance, environment fingerprint,
and option values return the same client, preserving this cache.
Each operation requests its own paymaster stub, then sponsorship data through pm_getPaymasterData. Before sponsorship, the SDK pads estimated account and paymaster gas limits
by 20% with a 50,000 gas floor, so the sponsorship signature commits to the buffered limits.
Submission failures after signing clear the gas-price cache, except a send timeout or an "already
known" answer; canceling a wallet prompt before signing completes preserves it.
Wait for inclusion with the submitted hash:
const receipt = await waitForPolyesterUserOperationReceipt(smartAccountClient, hash, {
timeoutMs: 120_000,
onPhase,
});
if (!receipt.success) throw new Error("UserOperation reverted");The helper checks pimlico_getUserOperationStatus immediately, then polls at the client's options.pollingIntervalMs (250 milliseconds by default). It obtains the receipt when the
operation is included or reverted. When the bundler reports rejected or failed, it throws UserOperationNotExecutedError (code USER_OPERATION_NOT_EXECUTED) carrying userOpHash and bundlerStatus: either status means the operation was not executed and nothing moved, so sending
a fresh operation is safe. A rejection also clears cached fees. A not_found status continues
polling and periodically checks for a receipt on-chain until the deadline. The default timeout is 120 seconds and bounds requests as
well as polling delays. Receiving a receipt does not imply execution succeeded; check success.
onPhase(phase, ms) reports prepare, sign, and send durations during submission. Pass it to
the receipt helper to report receipt duration too. Errors thrown by this timing observer are
ignored.
predictPolyesterSmartAccountAddress computes the address with no RPC calls.