npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 copytrading

No 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 test

Quickstart — 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 transfer or approve. With getSudoPolicy() 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. resume is a status flip and the worker mirrors forward from a cursor, so nothing is closed, opened or resized when a copy restarts. That is ignore, which is why it is the default here rather than the one we want. Until reconciliation ships, treat this section as detection: getDrift tells 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 both

resumeAgent 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 /agentsGET /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 wallet
  • TransactionSender — broadcast the UserOp or tx
  • actionPoliciesgetSudoPolicy() from @rhinestone/module-sdk is 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:

  1. Install the SmartSessions validator first. Enabling a session on an account that lacks the module reverts. installSmartSessionsModule handles the one-time setup.
  2. Serialize the session before returning it. It contains bigints (chainId, policy limits) and goes back to the API as JSON, so JSON.stringify throws unless you convert. This is the most common way a first spot integration fails.
  3. 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 allowKernelExecuteForSmartSessions hook — 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 + delete

listExecutions 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 all

The response contains secretthe 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 (pendingsubmittedconfirmed) 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 --noEmit

Publish

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-tags

License

UNLICENSED