Skip to content

feat: add Jupiter Limit Order V2 support (Solana)#328

Open
Pepewitch wants to merge 25 commits intomainfrom
feat/limit-order-v2
Open

feat: add Jupiter Limit Order V2 support (Solana)#328
Pepewitch wants to merge 25 commits intomainfrom
feat/limit-order-v2

Conversation

@Pepewitch
Copy link
Copy Markdown

@Pepewitch Pepewitch commented Mar 23, 2026

Summary

Add trade limit commands for Jupiter Limit Order V2 on Solana — create, list, cancel, and update trigger-based orders.

Commands

  • trade limit create — place a limit order (deposit + order in one flow)
  • trade limit list — list orders with --state active|past filtering, pagination, mint filter
  • trade limit cancel — cancel an order (withdrawal + confirmation)
  • trade limit update — update trigger price and/or slippage on an existing order

Design decisions

  • Amount convention matches swap: base units by default, --amount-unit token for human-readable (e.g. --amount 1.5 --amount-unit token)
  • Slippage convention matches swap: --slippage 0.03 (decimal, 3%) — converted internally to bps for the API
  • Shared utilities from trading.js: resolveTokenDecimals(), convertToBaseUnits(), validateBaseUnitAmount(), resolveTokenAddress() — no duplicate infrastructure
  • JWT auth: challenge-response with 23h TTL disk cache at ~/.nansen/limit-order-auth.json
  • Wallet support: local wallets, Privy, and WalletConnect
  • Auto vault registration: first-time users get a vault registered transparently
  • 5xx ghost-order recovery: if createOrder returns 5xx, checks recent orders (matching token pair, amount, trigger price, and timestamp) to warn about duplicates before the user retries

Files

File What
src/limit-order.js Core module: auth, signing, wallet resolution, all 4 command handlers
src/cli.js Route trade limit <sub> to limit-order handlers
src/schema.json Option docs for create, list, cancel, update
src/privy.js signSolanaMessage() for Privy wallets
src/walletconnect-trading.js signSolanaMessageViaWalletConnect() for WC wallets
src/__tests__/limit-order.test.js 96 unit tests

Test plan

  • npm test — 1378 tests pass (96 new for limit-order)
  • npm run lint — clean
  • Dogfooded locally:
    • Created orders: SOL to USDC, SOL to TRUMP, SOL to ORCA (all succeeded)
    • Created order: SOL to PUMP (correctly rejected — transfer hook extension)
    • Listed orders with --state active, verified formatting
    • Updated order (trigger price + slippage), verified via list
    • Cancelled all orders, verified withdrawal transactions on Solscan
    • Insufficient balance returns clean error (no misleading ghost-order warning)
  • 5xx ghost-order check: only triggers after createOrder call, not on pre-submission errors
  • Schema.json updated with correct API state values (active/past)

Generated with Claude Code

@Pepewitch Pepewitch force-pushed the feat/limit-order-v2 branch from a1def47 to fbb0817 Compare March 24, 2026 06:40
Copy link
Copy Markdown
Contributor

@TimNooren TimNooren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pepewitch Thanks🙌 Some small comments. My main concern is the interface, since it's going to be difficult to change once released. I think the current nansen trade quote/execute interface is not ideal, but I'd say having at least a consistent interface for now is more important, so let's stick with it.

Let's also add a changeset file🙏

src/cli.js Outdated
update Update trigger price or slippage

USAGE:
nansen trade limit-order create --from <token> --to <token> --amount <units> --trigger-mint <token> --trigger-condition <above|below> --trigger-price <usd>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC the unit here is the human-readable amount. In nansen trade quote/execute we use raw amounts (i.e. not adjusting for decimals). Maybe we should be consistent at least? We can add a --amount-unit, similar to #324.

Also, I saw that this needs to be >$10. Might also be good to document.

src/schema.json Outdated
},
"slippage-bps": {
"type": "number",
"description": "Slippage tolerance in basis points (e.g. 50 = 0.5%)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In nansen trade quote/execute we use slippage and it's a human-readable percentage. Would be good to be consistent.

src/schema.json Outdated
"amount": {
"type": "string",
"required": true,
"description": "Amount in base units (e.g. lamports)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is currently not correct right?


/**
* Resolve a Solana wallet for limit orders.
* Follows the same 3-way dispatch as trading.js: WalletConnect / named / default.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extract this code then instead of duplicating?


if (!orderId) {
log(`
Usage: nansen trade limit-order update --order <orderId> [--trigger-price <usd>] [--slippage-bps <bps>]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 nansen trade limit?

headers['Authorization'] = `Bearer ${token}`;
}
if (process.env.NANSEN_API_KEY) {
headers['X-API-Key'] = process.env.NANSEN_API_KEY;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably not be included.

Copy link
Copy Markdown
Contributor

@0xlaveen 0xlaveen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smoke test results (Privy wallet, Solana mainnet)

Ran 42 end-to-end tests against a Privy server wallet (Hm6a7ZkpPqTHZVqueaPNQRD4t7mg9DKPECpGo1QcFXjR) on mainnet. Full lifecycle verified across SOL→USDC, SOL→JUP, and SOL→WBTC (Wormhole) pairs. All 4 commands work. All 1180 unit tests pass.

Bug fixed during testing: --state query param should be --status — the API rejects state with "Request validation failed". Fix is in a follow-up commit on the branch.

Two issues flagged below in inline comments.

— Ari

const triggerPrice = options['trigger-price'];
const triggerCondition = options['trigger-condition'];
const triggerMintRaw = options['trigger-mint'];
const slippageBps = options['slippage-bps'] != null ? Number(options['slippage-bps']) : undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create doesn't validate slippageBps range client-side, but update does (line 831). If a user passes --slippage-bps 20000, it passes through to the API which rejects it with: Too big: expected number to be <=10000. The error message is decent, but for consistency with update we should add the same client-side check here:

if (slippageBps != null && (slippageBps < 0 || slippageBps > 10000)) {
  log('Error: --slippage-bps must be between 0 and 10000 basis points.');
  exit(1);
  return;
}

...(expiresAt != null ? { expiresAt } : {}),
};

const result = await createOrder(token, orderParams);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most impactful UX issue I found during testing. When createOrder returns a 500 Gateway Timeout (which happened intermittently from Jupiter), the CLI reports an error — but the order was actually created on the backend. The user thinks it failed, retries, and creates duplicates. During testing I accumulated 7 ghost orders this way.

Suggestion: catch timeout/5xx errors specifically and follow up with a listOrders check:

} catch (err) {
  if (err.status >= 500) {
    log(`Warning: Order submission returned an error, but may have succeeded.`);
    log(`  Checking order status...`);
    try {
      const check = await listOrders(token, pubkey, { limit: 1 });
      const recent = check.orders?.[0];
      if (recent && recent.inputMint === from && recent.outputMint === to) {
        log(`  Found recent order ${recent.id} (status: ${recent.status}). It may be yours.`);
        log(`  Run: nansen trade limit-order list`);
        return;
      }
    } catch { /* list check failed, fall through to error */ }
  }
  log(`Error: ${err.message}`);
  // ...
}

This saved me a lot of confusion during testing — orders were silently created despite the error output.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should trading-api protect against this instead of the user? And we should maybe rely on depositRequestId instead of ecent.inputMint === from && recent.outputMint === to? @Pepewitch

@Pepewitch Pepewitch force-pushed the feat/limit-order-v2 branch from 1ca2600 to e91fc90 Compare March 25, 2026 10:16
Pepewitch and others added 18 commits April 7, 2026 14:47
Add limit order commands under `nansen trade limit-order` powered by
the Jupiter Trigger API V2 backend. Supports create, list, cancel, and
update operations with all three wallet types (local, Privy, WalletConnect).

Key additions:
- src/limit-order.js: Core module with JWT auth/caching, API client,
  wallet resolution, and command handlers
- src/privy.js: Add signSolanaMessage() to PrivyClient
- src/walletconnect-trading.js: Add signSolanaMessageViaWalletConnect()
- src/cli.js: Wire limit-order sub-namespace into trade command
- src/schema.json: Add limit-order command definitions
- src/__tests__/limit-order.test.js: 50 comprehensive tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move limit order JWT caching from ~/.nansen/limit-order-auth.json
to macOS Keychain / Linux secret-tool, matching the existing pattern
in keychain.js for wallet password storage. Falls back gracefully
if keychain is unavailable (token just won't be cached).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add keychainStoreValue/keychainRetrieveValue to keychain.js as generic
exports, then use them from limit-order.js. This follows the existing
pattern of centralizing all OS keychain access in keychain.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keychain fallback (re-auth every command) causes excessive API hits.
Simple file at ~/.nansen/limit-order-auth.json with chmod 600 is
sufficient for a 24h JWT. Also fixes vault check to match actual
API response shape ({ vault: null } vs { vault: { ... } }).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove leftover account parameter from keychainStore/keychainRetrieve
that was introduced for the keychain-based JWT approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject EVM addresses and other invalid formats early with a clear
error message instead of letting them reach the Jupiter API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…create

Both flags are now required to prevent ambiguous orders (e.g. triggering
on a stablecoin price). Users must explicitly specify which token to
monitor and the direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ult check, better formatting

- Require --trigger-mint and --trigger-condition (no defaults)
- Fix vault existence check to match actual API response shape
- Show human-readable timestamps with ISO string in brackets
- Show full token addresses with symbol labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Amount is now always in token units (e.g. 1.5 = 1.5 SOL), converted to
base units internally. Uses parseAmount/getTokenInfo from transfer.js for
known tokens and RPC-based decimal lookup for arbitrary tokens.

Also adds 16 amount parsing tests covering precision, IEEE 754 edge
cases, truncation, and different token decimals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…st output

- Rename flag to --slippage-bps for clarity (100 = 1%)
- Always display slippage in order list (shows "auto" when not set)
- Document that auto slippage cannot be restored via update (backend limitation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
List now defaults to --state open. Use --state all to see everything.
Also improves error output with cause details for network errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove X-API-Key header from limit order requests (JWT auth only)
- Fix schema.json amount description to reflect human-readable units
- Rename subcommand from `limit-order` to `limit` for brevity
- Update help text to use `--amount <token-units>`
- Add TODO for extracting shared wallet resolution with trading.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Validate --slippage-bps range (0-10000) in create, matching update
- On 5xx errors during order creation, check recent orders to warn
  about potential duplicates before the user retries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Pepewitch Pepewitch force-pushed the feat/limit-order-v2 branch from e91fc90 to e44aa31 Compare April 7, 2026 08:15
Pepewitch and others added 6 commits April 9, 2026 12:55
- Amount defaults to base units (lamports), add --amount-unit token/usd
- Replace --slippage-bps (basis points) with --slippage (decimal pct, matching swap)
- Use shared resolveTokenDecimals/convertToBaseUnits from trading.js
- Remove duplicate KNOWN_SOLANA_TOKENS/parseAmount/getTokenInfo usage
- Update schema, help text, and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The API returns { vaultAddress: "..." } but the code checked for
vaultPubkey, causing it to always attempt re-registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…cies

- Remove --amount-unit usd (misleading for limit orders: converts at
  current price, not trigger price)
- Fix --state schema docs: "active, past" not "open, filled, cancelled, expired"
- Fix vault comment to match actual API field (vaultAddress)
- Fix update command slippage display to show percentage like formatOrder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 5xx error recovery: ghost order detection, no match, list failure, 4xx skip
- Vault: detects vaultAddress field (not just vaultPubkey)
- Create slippage: bps passthrough, auto omit, negative/overflow rejection
- Amount edge cases: zero, Infinity, negative
- Order ID resolution: options['order-id'] and args[0] for cancel/update
- Update combined: trigger-price + slippage together
- List filtering: --mint resolution, --sort/--dir, pagination, invalid mint
- signTransaction: local, privy, unsupported type
- WalletConnect: branch dispatch verification
- Rename slippageRaw → slippage to match trading.js convention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- cli.js help: --state <open|filled|cancelled|expired> → <active|past>
- Tests: state 'open' → 'active', 'filled' → 'past' (matches real API)
- Tests: mock vault responses use vaultAddress (matches real API response)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add orderSubmitted flag to skip ghost check on pre-submission errors
  (e.g. craftDeposit failures like "transfer hook extension")
- Store submittedAt timestamp before createOrder call
- Match ghost orders by inputMint, outputMint, inputAmount, triggerPriceUsd,
  AND createdAt >= submittedAt (not a hardcoded 60s window)
- Fetch 5 recent orders instead of 1 for more reliable matching
- Add test for pre-submission 5xx skipping ghost check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Deduplicates wallet resolution logic between trading.js and limit-order.js
by extracting a shared resolveWalletForChain() function in trading.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants