copytrading
v0.8.8
Published
Copy-trading SDK — typed API client and wallet orchestration for perps (Hyperliquid), spot (ERC-7579 smart sessions) and prediction-market agents
Readme
copytrading
Embeddable copy trading for wallets, terminals and trading apps. Your user picks a leader, the engine mirrors that leader's trades into their account under limits the user sets, and your app earns on every copied fill.
The whole integration is five calls: listVenues, listLeaders, createAgent,
activate, listExecutions. They behave the same on every venue rail (Hyperliquid
perps, EVM spot, Polymarket predictions), and execution runs on revocable, trade-only
credentials. What "trade-only" guarantees varies by venue; Custody,
honestly states it exactly. Leaders come pre-graded: the list you
get has already dropped the market makers and lucky streaks a follower cannot copy.
Docs: docs.unhosted.com/copy-trading. Partner keys are self-serve at unhosted.ai/partners: a sandbox key in minutes, paper mode against real leader data, no invite needed.
Install
npm install copytradingNo registry configuration, no tokens. The client runs in Node, browsers and edge runtimes.
Integrating with an AI coding agent? Point it at INTEGRATE.md, the
whole integration in one self-contained file. It ships inside the npm tarball.
Working on the SDK itself:
npm install
npm run build
npm testQuickstart — any venue, five calls
This is the path to use for new integrations. Agent creation is typed by venue because
predictions use sourceEoaAddress, while perps and spot use smartAccountAddress.
After creation, activate() is venue-generic: it validates each server-provided step
against a client-side allowlist (default strictActivation: true), then signs it. Pass
{ strictActivation: false } only if you validate steps yourself.
A compiled, CI-checked version of this is in
examples/multi-venue.ts.
import { CopyTradingClient, createWalletAuthHeaders } from 'copytrading';
const client = new CopyTradingClient({
baseUrl: API_URL,
apiKey: PARTNER_KEY,
// Owner-scoped routes need the user's signature too. Build this ONCE and reuse it —
// it holds the monotonic timestamp counter that prevents replay rejections.
getAuthHeaders: createWalletAuthHeaders({ address: userAddress, signer: personalSigner }),
});
// 1. What can you offer? Entitlements are per-partner, so ask rather than assume.
// Before rendering any "non-custodial" UI, see "Custody, honestly" below.
const venues = await client.listVenues();
// 2. Who is worth copying? Graded, not ranked by PnL — see "Leaders" below.
// Clean by default: only leaders that pass the copyability gates.
const leaders = await client.listLeaders({ venue: 'hyperliquid-perps' });
// 3. Create the agent. No chain-id sentinels, no strategy ids.
const { agentId } = await client.createAgent({
venue: 'hyperliquid-perps',
smartAccountAddress: userAddress,
leaderWalletAddress: leaders[0].address,
riskConfig: {
sizing: { mode: 'leader_percent', leaderPercent: 10 },
maxUsd: '500',
},
});
// Prediction agents use the source EOA instead of session/smart-account identity.
const predictionLeaders = await client.listLeaders({ venue: 'polymarket-predictions' });
const prediction = await client.createAgent({
venue: 'polymarket-predictions',
sourceEoaAddress: userAddress,
leaderWalletAddress: predictionLeaders[0].address,
riskConfig: {
maxUsd: '100',
sizing: { mode: 'equity_ratio', scale: 1 },
prediction: {
maxPortfolioUsd: '500',
authorityDurationDays: 7,
custodyRiskAcceptedAt: new Date().toISOString(),
},
},
});
// 4. One call, every venue. Walks whatever steps that venue needs.
await client.activate(agentId, signer);
// 5. Same feed shape everywhere.
const { executions } = await client.listExecutions(agentId);What signer has to implement
Only signTypedData is required. The rest are requested per venue, and a venue that needs
one you did not provide fails immediately with a message naming the method — not with a
signature error from inside an adapter.
| Method | Needed by |
|---|---|
| signTypedData | all venues |
| sendTransaction | spot session enablement |
Explaining each prompt to the user
Every activation step ships with its explanation — what the user is doing, why the flow needs it, what it deliberately cannot do, and what it costs. Render it around each wallet prompt instead of letting the user meet raw hex:
import { explainStep, costLabel } from 'copytrading';
const e = explainStep(step); // { title, what, why, safety, cost, walletShows }
// costLabel(e.cost) → "no gas" | "network fee" | "network fee (may be covered)"Unknown ids fall back to their type, unknown types to a generic-but-honest default — a step added server-side never renders blank. The safety lines are enforcement commitments (they hold even against a compromised backend); render them verbatim.
Venues
| id | kind | activation | end-user wallet | credentials expire |
|---|---|---|---|---|
| hyperliquid-perps | perps | 1–2 signatures, no gas | any EIP-712-signing EOA | never |
| spot | spot | one on-chain tx | ERC-7579 smart account | at session expiry |
| polymarket-predictions | prediction | Privy binding + policy signer attachment + approval batch | recoverable Privy embedded EOA bound to the selected source EOA | user-selected, max 7 days |
Note the wallet column is about the account implementation, not the brand:
MetaMask's smart-account mode (EIP-7702 → MetaMask's own delegation framework) is
not ERC-7579 and lands in the perps column for now — a MetaMask lane
(client_delegation_grant) is in development and already has its step explainer in
this SDK. Privy/Dynamic default embedded wallets are plain EOAs (perps column)
unless the provider's smart-wallets feature is enabled on a 7579 implementation.
Venue authorizations expire. For spot, handle the session.expiring webhook and
re-run activate(). Predictions uses an immutable policy-bound signer for at most
seven days: expiry stops new orders, the user removes the old signer, and a fresh
activation creates a new policy and signer. An expired agent cannot be resumed like
a merely paused agent.
Custody, honestly
"Non-custodial" is a per-venue fact here, not a marketing setting, and the SDK makes you check it rather than assume it.
- Hyperliquid perps — an agent key the user approves from their own wallet. It can trade, it cannot withdraw, and the user can revoke it at any time.
- Spot — an ERC-7579 smart session scoped to swap routers, with an expiry and a
usage limit. It cannot call
transferorapprove. WithgetSudoPolicy()it can swap to any recipient, because the allowlisted router functions take the recipient as an argument and that policy does not constrain arguments — so value can leave by that route. Pin the receiver argument if your users need more than trading-only. See Session scope. - Polymarket predictions — a user-owned Privy wallet with a policy-bound service signer capped at seven days. Funds the user places in its Deposit Wallet are at risk under a policy-valid backend compromise until the signer expires or is removed. Say so in your UI; the SDK will not say it for you.
Gate any "funds never leave your control" claim on the client-side custody table
(assertCustodySafe(venueId), or venue.custodyTrust === 'client-static'), never on
the server-reported fundsAtRiskOnServerCompromise flag alone. A compromised API can
lie about its own flag. The static table ships in your bundle and cannot.
Authentication
Every owner-scoped call carries two credentials, because two parties are involved:
| Credential | Header | Identifies | Required |
|---|---|---|---|
| Partner API key | Authorization: Bearer | your app (billing) | when calling through the gateway |
| Wallet signature | X-Wallet-Signature + X-Wallet-Address + X-Auth-Timestamp | the end user whose funds move | on every owner-scoped route |
The wallet signature is an EIP-191 personal_sign over a canonical message naming the
HTTP method and path, so a signature captured from one request cannot be replayed
against another. It expires after 5 minutes.
Do not hand-roll this message. The address must be EIP-55 checksummed — the server checksums before rebuilding the string, so a lowercase address signs a different message and returns a bare 401. Use the helper:
import { CopyTradingClient, createWalletAuthHeaders } from 'copytrading';
const api = new CopyTradingClient({
baseUrl: process.env.AGENT_API_URL!,
apiKey: process.env.COPY_TRADING_API_KEY, // partner key, if using the gateway
getAuthHeaders: createWalletAuthHeaders({
address: userAddress,
signer: { signMessage: (message) => wallet.signMessage(message) }, // viem/ethers/wagmi
}),
});Leaders
listLeaders is not the venue's leaderboard. It is the leaderboard after copyability
gates, and that is a much shorter list — on a live Polymarket screen, 2 of the top 12 by
monthly PnL survived; the rest were market makers whose fills a follower crossing the
spread cannot reproduce. The default is the clean list. Pass
mirrorableOnly: false only for a lookup surface — "why isn't the venue's #1 on your
board?" — where the gated-out entries and their reasons are the answer. Each leader
carries an assessment with plain-language
reasons. Render them: the reasons are the product, and assessment.evidence tells you
whether the verdict rests on real trade history or only on the venue's own figures.
The numbers behind a grade
assessment.metrics carries what the grade was computed from — recomputed from public
fills, not taken from the leaderboard — and assessment.flags names specific defects in
machine-readable form so you can badge and filter on them.
The pairing worth putting on the card is verifiedRoiPercent against
headlineRoiPercent. A leaderboard reports what flatters it: typically gross, typically
excluding funding, sometimes over a window that happens to start at the trader's low.
A follower earns the net number.
import {
resolveRoiDelta,
headlineOverstates,
sortLeaderFlags,
explainLeaderFlag,
} from 'copytrading';
const delta = resolveRoiDelta(leader.assessment?.metrics); // verified − headline, points
if (headlineOverstates(leader.assessment?.metrics)) {
warn(`Leaderboard shows ${m.headlineRoiPercent}%; fills show ${m.verifiedRoiPercent}%.`);
}
for (const flag of sortLeaderFlags(leader.assessment?.flags ?? [])) {
const e = explainLeaderFlag(flag); // { label, what, soWhat }
badge(flag.severity, e.label, flag.message); // message names the actual numbers
}Beyond the ROI pair, the fields that change a decision most:
| field | why it matters |
|---|---|
| netOfCostsRoiPercent | after fees and funding — on perps a headline return can be substantially funding-financed, and a follower entering later inherits the cost without the position that earned it |
| topTradePnlShare | the best single predictor that a record will not repeat. A year that is one trade is a next year that is a coin flip |
| edgePValue | edge or luck. <= 0.05 is the conventional bar; absent when the sample cannot support the test |
| closedTradeCount | what all of the above rests on |
resolveRoiDelta returns null, never 0, when either side is missing — "we could not
verify their number" and "we verified it and it matched" are opposite findings, and a zero
renders as the reassuring one. Same rule for every field here: absent means not
computed, not zero.
Unknown flag codes fall back to a generic-but-honest explanation, the same contract as
explainStep. Always render flag.message next to it — the fallback cannot name numbers
and the server's line can.
Skipped copies are normal
A mirror that would have been worse than not trading is recorded with
status: 'skipped' and a machine-readable skipReason (slippage_exceeded,
edge_exhausted, too_close_to_resolution, below_min_notional, …). Show it. On
prediction markets price
is the edge, so skipping is the system working, and a silent gap in the feed reads as a
bug to your users.
Sizing
The single most consequential field in riskConfig, and the one most likely to be set
wrong. Two modes:
| mode | size placed | use when |
|---|---|---|
| equity_ratio | (your equity / leader equity) × scale × their notional | default — the accounts differ in size, which is the normal case |
| position_fraction | the share of their stack they sold, applied to yours | spot — where no venue publishes account equity |
| leader_percent | leaderPercent% of their notional | the accounts are within an order of magnitude, or the user wants absolute sizes |
riskConfig: {
sizing: { mode: 'equity_ratio', scale: 1 },
maxUsd: '500', // prediction: total exposure in any one market
minUsd: '10', // below this, skip rather than place dust
prediction: {
maxPortfolioUsd: '1500', // total marked exposure across prediction markets
},
}Why equity_ratio is the default. leader_percent ignores the gap between the two
accounts. A whale opens $500k, you asked for 10%, and a $2k follower gets a $50k order
they cannot fund. Add maxUsd to stop that and you have created a worse problem: every
leader trade now clamps to the cap, so their $5k probe and their $500k conviction bet come
out the same size. That is not copying. Under equity_ratio the leader risking 5% of
their book puts 5% of yours at risk, and conviction survives the translation.
The ratio is recomputed from live equity on both sides at each trade, so it tracks your PnL instead of freezing at activation — double your account and you start taking double the size.
scale above 1 compounds with the leader's leverage rather than replacing it: scale: 2
on a leader running 10x is 20x exposure on your book, with the drawdowns doubled too.
Make the user choose it deliberately.
position_fraction — the spot answer
equity_ratio needs both accounts' equity. Perps venues publish it; spot does not, and
computing it there means asking an indexer to enumerate and price every holding of two
wallets on every swap — an external dependency in the hot path, priced in latency and
staleness.
So on spot, size against the stack being spent, which is on-chain, exact, and already needed for the affordability check:
riskConfig: { sizing: { mode: 'position_fraction', scale: 1 } }Leader sells 500 of their 1,000 USDC — half their stack. You hold 80 USDC, so you sell 40.
Same principle as equity_ratio: a percentage of your money, not theirs. A leader
selling half their USDC expresses the same conviction as you selling half of yours, and
you can never be sized past what you hold — scale: 2 front-loads exits rather than
enabling leverage.
It is not portfolio-risk-equivalent, and that is the trade: a leader dumping a position
worth 2% of their book maps to whatever share of your book that token happens to be.
Prefer equity_ratio wherever account equity is published.
resolvePositionFraction is the preview function, and it works in base units with
bigint rather than USD — an 18-decimal token must not lose its low bits to a float
round-trip. resolveCopySize rejects this mode and points you here.
import { resolvePositionFraction } from 'copytrading';
const d = resolvePositionFraction({
leaderSoldBase: '500000000', // 500 USDC
leaderBalanceBeforeBase: '1000000000', // read at the block BEFORE their swap
followerBalanceBase: '80000000',
maxPercentBalance: 25,
minBase: '5000000',
});
// → { amountBase: '20000000', requestedBase: '40000000',
// leaderFraction: 0.5, applied: ['max_percent_balance'] }Read the leader's balance at blockNumber - 1 of their swap, never at head. By the time
a mirror runs their swap has settled, so an unqualified read returns the post-swap
balance and every fraction comes out inflated — a leader who sold their whole stack would
divide by roughly zero.
Ceilings clamp, the floor skips
Deliberately asymmetric. A trade above your ceiling is still a trade you wanted, just
smaller — so a venue-supported exposure ceiling shrinks it and it executes. A trade
below minUsd is one whose fees exceed its point, so it is skipped with
below_min_notional; shrinking it further would be absurd and rounding it up to the
minimum would size past what the leader's conviction justified.
Expect below_min_notional to be common and normal when a small follower copies a large
leader. Render it as "too small to be worth the fees".
Previewing a size before the user commits
resolveCopySize is pure, runs client-side, and is the executable statement of what the
server does — use it for the "if they open $100k, you'd get $84" line next to the slider.
import { resolveCopySize, resolveSizingFromRiskConfig } from 'copytrading';
const decision = resolveCopySize({
sizing: resolveSizingFromRiskConfig(agent.riskConfig),
leaderNotionalUsd: 100_000,
leaderEquityUsd: 2_000_000,
followerEquityUsd: 10_000,
maxUsd: 500,
minUsd: 10,
});
// → { notionalUsd: 500, requestedUsd: 500, applied: [] }applied names every limit that moved the number ('max_usd',
'max_percent_equity', 'min_usd'), so you can show "capped at your 25% limit" rather
than an unexplained gap between what the leader did and what you got. It throws on
incoherent input — notably equity_ratio without leaderEquityUsd — instead of
degrading to 1:1 notional copying, which is the exact failure the mode exists to prevent.
Stopouts — when the copy halts itself
Sizing limits act on one trade. Stopout limits act on the copy as a whole and stop it entirely:
riskConfig: {
stopout: {
maxDrawdownPercent: 20, // your equity, from its peak since activation
dailyLossLimitUsd: '250', // realised, rolling 24h
consecutiveFailures: 3, // mirrors that failed in a row
leaderMaxDrawdownPercent: 30, // the *leader's* drawdown, not yours
},
}Set maxDrawdownPercent at least. Users assume it exists, and an unbounded copy is one
they have to babysit — which is the thing they delegated.
leaderMaxDrawdownPercent is the one integrators forget. Your user's account can look
fine — the copy started last week and is barely down — while the leader is mid-blowup.
Without it you ride the whole way down behind them before your own limit catches up.
stopped_out is not paused
| status | who stopped it | how it restarts |
|---|---|---|
| paused | the user | resumeAgent() |
| stopped_out | a risk limit | clearStopout() — resumeAgent() is rejected |
| expired | credentials lapsed | re-run activate() |
These were the same status until now, and collapsing them meant the worst moment in the product — the drawdown limit firing — arrived as a state change indistinguishable from something the user did on purpose, with the obvious remedy putting them straight back into the position that just stopped them out.
The separate call is the point. Make the user look at what happened before it starts again:
import { activeStopout, explainStopout, requiresOwnerAction } from 'copytrading';
const agent = await api.getAgent(agentId);
if (requiresOwnerAction(agent.status)) {
const stopout = activeStopout(await api.listStopouts(agentId));
if (stopout) {
const e = explainStopout(stopout);
// e.title → "Copy halted — your drawdown limit"
// e.what → one sentence on what happened
// e.consider → what to weigh before restarting
// e.resumeAdvice → 'review-first' | 'safe-to-resume' (primary vs secondary button)
render(e, stopout.message); // stopout.message names the actual numbers
}
}
await api.clearStopout(agentId); // acknowledges and returns to `active`listStopouts returns cleared halts too, so you can render the history — "this is the
third time this leader has tripped your drawdown limit" is the sentence that ends a bad
subscription. Pass { active: true } for only the outstanding one.
Clearing restarts from the leader's current position, not the one that stopped you out. Nothing is re-entered retroactively.
Unknown reasons — a limit added server-side after your SDK build — fall back to a
generic-but-honest explanation advising review, the same contract as explainStep.
Resuming is a decision, not a no-op
Every halt — a pause, a stopout, an expired session, an incident on our side — opens a window where the leader keeps trading and your user does not. When the copy comes back the two books no longer match.
The dangerous half of that is not the trade your user missed. It is the position they still hold that the leader already exited: no stop, no thesis, and the person who had both left days ago. So resuming applies a policy:
| resyncPolicy | orphans (leader exited) | missed (leader entered) | drifted sizes |
|---|---|---|---|
| ignore (default today) | left alone | left alone | left alone |
| close_orphans (intended default) | closed | left alone | left alone |
| adopt | closed | opened | resized |
The server does not reconcile yet.
resumeis a status flip and the worker mirrors forward from a cursor, so nothing is closed, opened or resized when a copy restarts. That isignore, which is why it is the default here rather than the one we want. Until reconciliation ships, treat this section as detection:getDrifttells you which positions are orphaned so your UI can warn, or so your app can close them through its own trading path. Do not render "resuming will close 2 positions" against a server that will not.
close_orphans is the intended default — DEFAULT_RESYNC_POLICY flips to it when the
backend lands — because the two alternatives both fail quietly. ignore leaves the
unmanaged position sitting there. adopt sounds correct, match the leader, but it
market-buys into trades that have already moved, at prices the leader never paid, at the
moment the user is least attentive. Missing a trade costs an opportunity; holding a
position nobody is steering costs money.
Set it per agent in riskConfig.resyncPolicy, or override for one restart:
import { planResync, resyncHasWork, describeResyncPolicy } from 'copytrading';
const drift = await api.getDrift(agentId);
const plan = planResync(drift.positions, agent.riskConfig?.resyncPolicy);
// Today this is the branch that fires: nothing is reconciled, so say so.
if (plan.leavesOrphans) {
warn('You still hold positions the leader has exited. Nothing is managing them.');
}
// Once the server reconciles, this becomes the confirmation step:
// "Resuming will close 2 positions and open 1."
if (resyncHasWork(plan)) {
confirm(plan.summary, describeResyncPolicy(plan.policy));
}
await api.resumeAgent(agentId, { resyncPolicy: 'close_orphans' });
await api.clearStopout(agentId, { resyncPolicy: 'adopt' }); // same option on bothresumeAgent and clearStopout accept resyncPolicy now so integrations do not need a
signature change later; the server ignores it until reconciliation lands.
planResync is pure and takes no network — it is what you render before the user
confirms. Positions the plan will not touch come back as action: 'none' with a reason
rather than being dropped, so you can show the whole book and explain every row.
Which integration are you? (venue-specific paths)
The venue-specific clients below predate activate() and remain supported. Use them if
you need fine control over a particular venue's handshake; otherwise prefer the quickstart.
| | Perps (Hyperliquid) | Spot (smart sessions) |
|---|---|---|
| Wallet requirement | any EOA that can signTypedData | ERC-7579 smart account |
| On-chain tx to activate | none | one (enable session) |
| Gas | none | user or paymaster |
| You must implement | nothing | a SpotSessionAdapter |
Start with perps. It needs no smart-account infrastructure — one signature and the copy is live. Spot requires you to build/install/revoke smart sessions in your own wallet stack, which the SDK models but does not implement for you.
Perps quickstart
Three calls and a signature loop. Hyperliquid is not an EVM chain, so its strategies are
catalogued under chainId: 0.
import { CopyTradingClient, PerpsCopyClient } from 'copytrading';
const perps = new PerpsCopyClient({
api,
signer: { signTypedData: (payload) => wallet.signTypedData(payload) },
});
// 1. pick a leader
const strategies = await api.listStrategies(0);
// 2. create the agent, sign the Hyperliquid approvals, submit them
const { agentId } = await perps.activate({
smartAccountAddress: userAddress,
leaderWalletAddress: '0x…',
sizing: { mode: 'leader_percent', leaderPercent: 100 },
maxUsd: '1000', // per-trade cap
minUsd: '10', // below this, skip rather than place dust
maxLeverage: 3,
});
// 3. show the user what it did
const { executions } = await api.listExecutions(agentId);Under the hood step 2 is: POST /agents → GET /perps/hyperliquid/approve-actions →
sign each returned action → POST /perps/hyperliquid/approve with
{ venue, agentAddress, approvals[] }. Call the endpoints directly if you'd rather not
use PerpsCopyClient, but send that exact shape — a bare { signature } is rejected.
Spot quickstart
Spot copying installs an ERC-7579 smart session: a scoped, expiring permission that
lets the backend's session key call swap routers on the user's behalf and nothing else.
It cannot call transfer or approve, and it stops working on its own at validUntil.
Read Session scope: what it does and does not constrain
before you choose actionPolicies. With the sudo policy this page used to recommend
without qualification, "cannot call transfer" is not the same as "funds cannot leave".
Activation is wallet-specific. The SDK handles API calls, session policy shapes and the enable/revoke sequence; you provide:
SpotSessionAdapter— build/install/revoke smart sessions in your walletTransactionSender— broadcast the UserOp or txactionPolicies—getSudoPolicy()from@rhinestone/module-sdkis the common choice; understand what it does not constrain first (see below)
Start from examples/spot-session-adapter.ts —
a working reference extracted from the production Unhosted wallet, typechecked in CI
against @rhinestone/module-sdk and viem. It is ~200 lines, most of it Rhinestone
calls. Three things it will save you:
- Install the SmartSessions validator first. Enabling a session on an account that
lacks the module reverts.
installSmartSessionsModulehandles the one-time setup. - Serialize the session before returning it. It contains bigints (
chainId, policy limits) and goes back to the API as JSON, soJSON.stringifythrows unless you convert. This is the most common way a first spot integration fails. - On Kernel, grant the execute selector. Kernel v3.3 gates which selectors a
validator may call; without it the session enables fine and every copied trade
reverts. That step is isolated behind the optional
allowKernelExecuteForSmartSessionshook — Safe7579 and Nexus omit it entirely.
import { CopyTradingClient, SpotCopyClient } from 'copytrading';
import { getSudoPolicy } from '@rhinestone/module-sdk';
const spot = new SpotCopyClient({
api,
sessionAdapter: myWalletSessionAdapter,
transactionSender: myTransactionSender,
});
await spot.activate({
strategy, // from api.listStrategies(chainId)
chainId: 8453,
smartAccountAddress: '0x…',
maxPositionSize: '100', // per-trade cap, USDC
sizing: { mode: 'leader_percent', leaderPercent: 100 },
sessionDurationDays: 30,
actionPolicies: [getSudoPolicy()],
});The session is scoped to swap routers only (LiFi, 1inch, Uniswap Universal Router, 0x AllowanceHolder) with a time window and a usage limit.
Session scope: what it does and does not constrain
A smart session constrains three things: which contract may be called, which
function on it, and how often / for how long. getSudoPolicy() adds no fourth
constraint — it permits any arguments to those functions.
That distinction matters here, because swap routers take a recipient as an argument. Two of the allowlisted LiFi selectors are:
| selector | signature |
|---|---|
| 0x5fd9ae2e | swapTokensMultipleV3ERC20ToERC20(bytes32, string, string, address _receiver, uint256, (…)[]) |
| 0x4630a0d8 | swapTokensGeneric(bytes32, string, string, address _receiver, uint256, (…)[]) |
The 4th argument is where the output goes. 1inch's dstReceiver, the Uniswap Universal
Router's recipient inputs and 0x AllowanceHolder's exec(operator, …, target, data) are
equivalent.
So, with getSudoPolicy():
| | |
|---|---|
| The session key cannot | call transfer, call approve, touch a contract outside the router allowlist, act after validUntil, or exceed the usage limit |
| The session key can | swap the account's tokens and name any address as the recipient |
Whoever holds the session key — our backend, or anyone who compromises it — can therefore move value out of the account by swapping to an address of their choosing. The limits above are real and they are not nothing, but a session built this way is trading-only, not custody-safe. Earlier revisions of this page said "it cannot transfer out". That was wrong, and this section replaces it.
If that trust assumption is not acceptable for your users, constrain the arguments as
well as the target. getUniversalActionPolicy() from @rhinestone/module-sdk takes
paramRules that pin a specific calldata offset to a value — pin each selector's receiver
argument to the user's own smart-account address and the swap-to-elsewhere path closes:
import { getUniversalActionPolicy } from '@rhinestone/module-sdk';
// One rule set per selector: the receiver's offset differs between them, and a rule
// written against the wrong offset silently constrains the wrong 32 bytes.We do not yet ship a ready-made rule set for every allowlisted selector, and an untested one is worse than none — it reads as a guarantee while constraining the wrong word. If you need argument pinning before we publish ours, derive the offsets from the signatures above and verify against a real calldata sample on a testnet.
Whichever you choose, describe it to your users accurately. A wallet that tells people their funds cannot leave, on a session that permits swap-to-anywhere, is making a promise the chain does not enforce.
Predictions: account lifecycle
Prediction agents run from a dedicated, user-owned Privy wallet.
Prediction activation is resumable. Persist agentId and every state delivered by
the onState activation callback. After an app or process restart, call
activate() again for that same agent. Do not create a replacement pending agent.
The repeated predictionContext lets the client pin the source EOA, Privy user,
Privy wallet, Privy owner, Deposit Wallet, policy hash and expiry even when an
already-confirmed step is omitted from the resumed round.
Prediction account controls are also exposed directly:
const challenge = await client.createPredictionBindingChallenge({
privyAccessToken,
sourceEoaAddress,
});
const account = await client.bindPredictionAccount({
privyAccessToken,
challengeId: challenge.challengeId,
message: challenge.message,
signature: await sourceEoaSigner.signMessage(challenge.message),
privyWalletId,
privyOwnerAddress,
});
const deposit = await client.getPredictionDepositHandoff({
sourceEoaAddress,
depositWalletAddress: locallyPersistedDepositWalletAddress,
}); // Polygon pUSD handoff for the wallet's normal send-token flow
await client.withdrawPredictionCollateral(privyOwnerSigner, {
expectedDestination: sourceEoaAddress,
expectedDepositWalletAddress: locallyPersistedDepositWalletAddress,
});
await client.redeemPredictionPositions(privyOwnerSigner, {
expectedDepositWalletAddress: locallyPersistedDepositWalletAddress,
}); // exact owner-signed fallback when auto-redeem has not completed
await client.deleteAgent(agentId); // stop server execution first
await privyWallet.removeSigners(); // dedicated wallet: removes all additional signers
await client.confirmPredictionPrivyCleanup(agentId);
const summary = await client.getPredictionAccountSummary();
await client.closePredictionPosition(privyOwnerSigner, {
tokenId: summary.openPositions![0].tokenId,
percentage: 50, // integer 1–100; 100 exits the full available holding
expectedDepositWalletAddress: locallyPersistedDepositWalletAddress,
expectedPrivyOwnerAddress: privyOwnerAddress,
});Cleanup and withdrawal are intentionally separate so a changing balance cannot block
authority revocation. Disable never initiates withdrawal. Withdrawal occurs only after an
explicit user action and defaults to the Deposit Wallet's on-chain pUSD balance, so it
remains available after recovery without an active agent or reusable CLOB credentials.
When authenticated CLOB availability is available, the backend can cap this to the
smaller free amount. Without it, callers should surface the returned warning that any
unreconciled open orders could become underfunded. Withdrawal does not use the backend
order key, and it never treats open-position value as cash.
Deposit Wallet approvals and withdrawals are exact Privy-owner-signed batches
submitted through the authenticated Polymarket Relayer. Signer revocation happens
through Privy's client SDK and is independently verified by the backend.
Deposits are also explicit user actions. The worker trades only from already available
Deposit Wallet collateral and skips BUYs that cannot be fully funded.
New account setup approves Polymarket's official auto-redeem operator and both
collateral adapters. Account summaries expose auto-redeem health plus any resolved
positions that still require the explicit owner-signed redemption fallback above.
After disable, account.openPositions drives partial or full owner exits. The SDK
pins and signs one exact FAK SELL, and the server posts it using cleanup-only CLOB
credentials that cannot create a signature or reactivate the strategy.
Managing a live copy
await api.getAgent(agentId);
await api.listExecutions(agentId, { limit: 50 }); // page with { before: nextBefore }
await api.updateRiskConfig(agentId, { maxUsd: '250' }); // no session teardown
await api.pauseAgent(agentId);
await api.resumeAgent(agentId); // `paused` only
await api.listStopouts(agentId); // risk halts, newest first
await api.clearStopout(agentId); // acknowledge a `stopped_out`
await api.getDrift(agentId); // your book vs the leader's
await perps.deactivate(agentId, userAddress); // revoke on venue + deletelistExecutions is the copy history you render for the user: one row per attempted
mirror, with status, txHash/userOpHash, the details of what was attempted, and
error when the venue or chain rejected it.
Webhooks
Register a receiver once and stop polling. Webhook routes are partner-level — they take your API key alone, no wallet signature, and one endpoint covers every agent you create.
POST /v1/agent/webhooks
{ "url": "https://you.example.com/hooks/copytrade",
"events": ["execution.confirmed", "execution.failed"] } // omit for allThe response contains secret — the only time it is shown.
| Event | Fires when |
|---|---|
| execution.submitted | a mirrored trade was sent to the chain/venue |
| execution.confirmed | it landed |
| execution.failed | it reverted or was rejected |
| execution.skipped | a risk limit or guard prevented it (error says which) |
| agent.session_expiring | a spot session expires within 48h, warned once |
| agent.stopped_out | a risk limit halted the copy — handle this one even if you ignore the rest |
Verify every request against the raw body — re-serializing parsed JSON changes the bytes and breaks the signature:
import { parseWebhookPayload } from 'copytrading';
export const config = { api: { bodyParser: false } }; // Next.js pages API
export default async function handler(req, res) {
const raw = await readRawBody(req);
let event;
try {
event = parseWebhookPayload({
secret: process.env.COPYTRADE_WEBHOOK_SECRET!,
body: raw,
header: req.headers['x-unhosted-signature'],
});
} catch {
return res.status(400).end(); // never act on an unverified payload
}
// At-least-once delivery: dedupe on event.id before doing anything with side effects.
await handleCopyEvent(event);
res.status(200).end();
}No server? Poll instead
Webhooks need somewhere to deliver to. A pure-frontend app, a mobile client or a notebook has nowhere, so those two loops ship in the SDK rather than getting hand-rolled once per integration:
// Page backwards through history. `break` stops the paging immediately.
for await (const execution of api.iterateExecutions(agentId)) {
if (execution.createdAt < cutoff) break;
render(execution);
}
// Live feed. Emits each execution once, oldest-first within a poll.
const controller = new AbortController();
for await (const execution of api.watchExecutions(agentId, { signal: controller.signal })) {
toast(execution);
}watchExecutions establishes a watermark on its first poll and emits nothing from it, so
opening the page does not fire a notification per historical row — pass
{ includeExisting: true } to backfill. It keys on execution id, not a timestamp,
because a row's status settles (pending → submitted → confirmed) without
createdAt moving; a timestamp watcher either replays the row on each transition or
misses the confirmation. A failing poll goes to onError and the loop continues.
This is a fallback, not a webhook substitute: one request per interval per viewer, it sees only what a poll catches, and it stops when the tab does.
Non-2xx responses are retried at 1m, 5m, 30m, 2h, 6h and then dropped. Inspect attempts
with GET /v1/agent/webhooks/:id/deliveries — status, response code, and the last error
per attempt. The verifier is dependency-light and runs in Node, browsers and edge
runtimes.
What is live, and what is contract-only
This SDK is a client. Some of what it declares is enforced by the agent service today; some is the agreed shape for work that has not landed there yet. Both are typed and both compile, so the distinction is not visible from the types — it is here.
| Surface | Status |
|---|---|
| Venues, activation, leaders, executions, webhooks, risk config, pause/resume | Live |
| iterateExecutions / watchExecutions | Live — pure client-side paging over the existing endpoint |
| resolveCopySize, planResync, explainStopout, explainLeaderFlag | Live — pure functions, no server involved |
| sizing.equity_ratio, maxUsd, prediction.maxPortfolioUsd, minUsd | Enforced by the prediction service from live leader/follower equity and exposure |
| stopped_out, listStopouts, clearStopout, agent.stopped_out | Contract only — the service has no stopout concept yet |
| getDrift, server-side resyncPolicy | Contract only — resume does not reconcile positions |
| denyTokens / denyMarkets | Contract only |
| assessment.metrics / assessment.flags | Contract only — the intelligence layer has to compute them |
The pure functions are useful immediately: preview a size, detect orphaned positions, render a leader's flags. The rest describes the target so an integration written against it does not need a signature change when the backend lands.
Package layout
| Module | Purpose |
|--------|---------|
| CopyTradingClient | Raw /v1/agent/* HTTP API |
| createWalletAuthHeaders | Per-request EIP-191 wallet auth |
| PerpsCopyClient | Create agent → HL approve → submit |
| SpotCopyClient | Create agent → session on-chain → session-enabled |
| buildSpotSessionActions | Router allowlist for spot sessions |
| adapters | Signer, SpotSessionAdapter, TransactionSender interfaces |
Subpath exports:
import { PerpsCopyClient } from 'copytrading/perps';
import { SpotCopyClient } from 'copytrading/spot';Errors
Every failure is a CopyTradingError carrying status, code, venue, stage,
retryable, cleanupRequired, and the API's structured detail (details remains a
backward-compatible alias). Requests time out after 30s (timeoutMs) with code: 'timeout' rather
than hanging your UI.
Full API reference
The agent service publishes OpenAPI at /docs (JSON at /docs/openapi.json) — generated
from the same Zod schemas that validate requests, so it cannot drift from what the server
accepts.
Scripts
npm run build # compile to dist/
npm test # unit tests
npm run typecheck # tsc --noEmitPublish
Publishing is manual. CI has no npm token, so the tag-triggered job cannot publish;
releases happen from a maintainer machine, from main only:
npm version <patch|minor|major>
npm publish
git push --follow-tagsLicense
UNLICENSED
