From 060e237f624de7697bc7822d2668c47ac5e84938 Mon Sep 17 00:00:00 2001 From: Wraith <68072890+wraithioner@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:38:11 +0200 Subject: [PATCH 1/4] Fix transaction uncertainty, automation retries, and wallet recovery --- .github/workflows/check.yml | 21 ++ README.md | 14 +- REVIEW.md | 75 +++++++ package.json | 11 +- scripts/copytrade-regressions.ts | 277 ++++++++++++++++++++++++++ scripts/portfolio-regressions.ts | 118 +++++++++++ scripts/reconcile-regressions.ts | 159 +++++++++++++++ scripts/smoke.ts | 8 +- scripts/transaction-regressions.ts | 307 +++++++++++++++++++++++++++++ scripts/wallet-regressions.ts | 246 +++++++++++++++++++++++ src/bot/handlers/core.ts | 13 +- src/bot/index.ts | 6 +- src/bot/session.ts | 21 +- src/bot/ui.ts | 1 + src/chains/solana.ts | 48 +++-- src/services/copytrade.ts | 41 +++- src/services/portfolio.ts | 7 + src/services/reconcile.ts | 45 +++-- src/services/watcher.ts | 147 +++++++++++--- src/store/db.ts | 47 ++++- src/store/vault.ts | 108 ++++++---- src/store/wallets.ts | 13 +- src/trade/engine.ts | 96 +++++---- src/trade/errors.ts | 21 ++ src/trade/fund.ts | 5 + src/trade/jito.ts | 40 +++- src/types.ts | 2 + tsconfig.json | 2 +- 28 files changed, 1717 insertions(+), 182 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 REVIEW.md create mode 100644 scripts/copytrade-regressions.ts create mode 100644 scripts/portfolio-regressions.ts create mode 100644 scripts/reconcile-regressions.ts create mode 100644 scripts/transaction-regressions.ts create mode 100644 scripts/wallet-regressions.ts create mode 100644 src/trade/errors.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..9ffac71 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,21 @@ +name: Check + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + offline-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check diff --git a/README.md b/README.md index e61e6a5..f8a593f 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ you can configure. | Master key | Random 32 bytes in `data/vault.key` at 0600, held in one closure, zeroed on shutdown | | Secrets in chat | Private keys and seed phrases are deleted from the chat on receipt; exports self-destruct after 60s | | Logs | A redaction filter strips anything shaped like a private key before it's written | -| Access | Non-owner updates are dropped without a reply | +| Access | Only owner updates in private chats are accepted; group and channel updates are dropped | | Destructive actions | Every write operation requires a second confirming tap | **What this does not protect against: anyone who can read the data directory.** @@ -495,10 +495,10 @@ Past +25% the screen says so in bold. npm run check ``` -Runs three layers: +Runs offline checks, also enforced on pull requests by GitHub Actions: - `typecheck` — full TypeScript strict-mode pass -- `smoke` — 157 offline assertions: vault crypto (round-trip, unique IVs, tamper +- `smoke` — the existing offline suite: vault crypto (round-trip, unique IVs, tamper rejection, dropping a passphrase without losing a key, a key file that is wrong or missing being refused loudly, and a vault that opens itself at boot), wallet @@ -514,7 +514,13 @@ Runs three layers: caught here rather than in Telegram), a check that every button the keyboards emit reaches a route — a dead button looks exactly like a slow one — address parsing, concurrency helpers, log redaction -- `netcheck` — 20 live read-only checks against Solana RPC, DexScreener, Jupiter, +- `regressions` — mocked transaction responses, automation failures, vault + migration write failures, private-chat access, expiring confirmations, and + incomplete portfolio reads. These tests never contact Telegram or an RPC. + +Run `npm run check:live` to add the network checks, or run them individually: + +- `netcheck` — live read-only checks against Solana RPC, DexScreener, Jupiter, PumpPortal and the pump.fun program, including that Jupiter can still route a token on its bonding curve — a fallback nobody verifies is a fallback that fails the first time it is needed diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..27a4f80 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,75 @@ +# Reliability review — 2026-10-02 + +Reviewed transaction submission, funding and sweeps, copy trading, automated +exits and DCA, wallet persistence, Telegram access, sessions, portfolio valuation, +and validation. Baseline: `817fd8f00ef908d6e3590b282e002f5df261b8ae`. + +The existing single-operator architecture is reasonable for this workload: +bounded concurrency, mint locks, atomic persistence, and conservative safety +gates are useful foundations. The review found correctness gaps in failure +handling that those safeguards alone did not cover. + +## Implemented corrections + +| Area | Problem | Result | +| --- | --- | --- | +| Jito | Successful `{ Ok: null }` responses were reported as failures | Confirmed and finalized successful bundles count as fills | +| Submission | A lost response or absent status could lead to rebuilding a spend | Preserve the signed identity and flag unknown outcomes; only a confirmed rejection permits retry | +| Automation | Notification/accounting errors could rearm a filled rule | Confirmed fills stay claimed; uncertain submissions stop automatic replay | +| Exits | Failed balance reads looked like an empty position | Unreadable balances preserve protection for a bounded retry | +| DCA | Updating the store mutated the saved round counter | Restore the original counter for definite zero-fill failures; pause uncertain execution | +| Copy safety | Unknown holdings were treated as no exposure | Refuse the buy when exposure cannot be established | +| Telegram | Owner actions could expose secrets in a group; stale confirmations remained valid | Accept private chats only and enforce expiry when a confirmation is used | +| Session IDs | A random collision could redirect an existing button | Allocate unused IDs without overwriting mappings | +| Wallet addresses | Lowercasing conflated distinct Solana addresses | Match base58 addresses exactly | +| Vault migration | Ciphertext could be rewritten before its new key was saved | Save the verified existing key before atomically changing vault mode | +| Storage recovery | A missing primary could discard the surviving backup | Recover validated backups and refuse fresh vault creation over existing secrets | +| Valuation | Partial reads could become false losses or permanent history marks | Label known value, withhold incomplete P&L, and record only complete valuations | +| Group views | Group holdings were compared with account-wide cost | Keep account P&L on the complete account view | +| History repair | Truncated scans and missing parsed transactions were called complete | Preserve measured proceeds and report an incomplete scan | +| Sweeps and fees | Non-associated token balances were transferred from the wrong account; zero priority still charged a fee | Transfer and close the actual source account; honor zero priority pricing | +| Validation | Network outages determined the normal check result | Run strict typechecking and offline behavioral tests in CI; keep live checks separate | + +## Verification + +Run `npm run check` for strict typechecking, the existing smoke suite, and the +new offline regressions. The new tests inject RPC/trading failures, exercise real +bot middleware with a fake Telegram API, and inject persistence failures. They +use temporary data and never broadcast transactions. + +`npm run check` passed after these changes: strict typechecking, **279 smoke +checks**, **27 transaction**, **15 automation**, **7 portfolio**, **9 history +reconciliation** cases, and the wallet/auth/persistence regression suite. + +The live read-only `npm run netcheck` run returned **25 passed, 1 failed**. +The failure was the BONK Rugcheck lookup timing out; other Rugcheck probes +answered. The public RPC also returned holder-query rate limits. This result +does not establish uninterrupted upstream availability or production execution. + +## Further improvements + +- Persist a transaction journal before submission, then reconcile pending + signatures after restart. This would let unknown outcomes recover their + ledger entries automatically; the present fix stops replay and asks the + operator to check the wallets. +- Separate copy-event receipt from successful transaction parsing. An RPC + parse failure currently consumes the signature and can miss a copy. A + durable queue needs distinct received, parsed, and executed states so that + retrying reads cannot duplicate execution. +- A wallet can hold one mint in several token accounts. A comprehensive sweep + should process every account and report any remainder. + +## Dependency findings + +`npm audit --omit=dev` reported **9 findings: 3 high and 6 moderate**, including +inherited package findings. No forced dependency downgrade or major override +was applied. The installed Solana packages still pull the affected dependencies. + +| Advisory | Applicability review | +| --- | --- | +| [bigint-buffer](https://github.com/advisories/GHSA-3gc7-fjrx-p6mg) | No patched release is listed. SPL-token uses fixed-width u64 layouts; the reviewed paths did not demonstrate exploitation. It remains a dependency risk, especially where native bindings are installed. | +| [stream-json](https://github.com/advisories/GHSA-528h-pc64-c93x) | Affects streaming filters; the reviewed web3 client path uses JSON.parse rather than those filters. | +| [uuid](https://github.com/advisories/GHSA-w5hq-g745-h8pq) | Affects v3/v5/v6 output buffers; the reviewed Jayson path calls v4 without a buffer. | + +These are applicability observations, not a clean security audit. Track upstream +compatible fixes and rerun the audit when changing the Solana dependencies. diff --git a/package.json b/package.json index 79e518f..def66b1 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,13 @@ "start": "tsx src/index.ts", "dev": "tsx watch src/index.ts", "typecheck": "tsc --noEmit", - "smoke": "tsx scripts/smoke.ts", - "netcheck": "tsx scripts/netcheck.ts", - "check": "npm run typecheck && npm run smoke && npm run netcheck", - "batchsim": "tsx scripts/batchsim.ts" + "smoke": "node --import tsx scripts/smoke.ts", + "regressions": "node --import tsx scripts/transaction-regressions.ts && node --import tsx scripts/copytrade-regressions.ts && node --import tsx scripts/wallet-regressions.ts && node --import tsx scripts/portfolio-regressions.ts && node --import tsx scripts/reconcile-regressions.ts", + "test": "npm run smoke && npm run regressions", + "netcheck": "node --import tsx scripts/netcheck.ts", + "check": "npm run typecheck && npm test", + "check:live": "npm run check && npm run netcheck", + "batchsim": "node --import tsx scripts/batchsim.ts" }, "dependencies": { "@solana/spl-token": "^0.4.9", diff --git a/scripts/copytrade-regressions.ts b/scripts/copytrade-regressions.ts new file mode 100644 index 0000000..102a9eb --- /dev/null +++ b/scripts/copytrade-regressions.ts @@ -0,0 +1,277 @@ +/** Offline execution regressions. Every RPC and trade operation is injected. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { AutoRule, CopyTarget, DcaPlan } from '../src/store/db.js'; +import type { BatchSummary, ExecutionResult, WalletRecord } from '../src/types.js'; +import type { WatcherTradeServices } from '../src/services/watcher.js'; +import type { CopyBuyServices } from '../src/services/copytrade.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-copy-regressions-')); +process.env.BOT_TOKEN = '123:OFFLINE_TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; +process.env.VAULT_AUTOLOCK_MINUTES = '0'; + +const { db } = await import('../src/store/db.js'); +const { fire, runDueDca } = await import('../src/services/watcher.js'); +const { mirrorBuy } = await import('../src/services/copytrade.js'); + +const wallet: WalletRecord = { + id: 'offline-wallet', + kind: 'solana', + address: 'offline-address', + label: 'Offline wallet', + secret: '', + groups: [], + isMain: false, + disabled: false, + createdAt: 1, +}; +const result = (patch: Partial = {}): ExecutionResult => ({ + walletId: wallet.id, + address: wallet.address, + label: wallet.label, + ok: true, + signature: 'offline-confirmed-signature', + ...patch, +}); +const summary = (results = [result()]): BatchSummary => ({ + results, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + startedAt: 1, + finishedAt: 2, + solReceived: 0.1, + solSpent: 0.05, +}); +const rejected = () => summary([result({ ok: false, signature: undefined, error: 'Build rejected' })]); +const uncertain = () => summary([ + result({ ok: false, signature: 'offline-pending-signature', error: 'Confirmation timed out', confirmationUnknown: true }), +]); +const noopNotify = async (_text: string) => {}; +const rejectNotify = async (_text: string) => { throw new Error('Telegram unavailable'); }; +const baseServices = (): WatcherTradeServices => ({ + selectWallets: () => [wallet], + getMintBalances: async () => new Map([[wallet.address, 100_000_000n]]), + batchPumpTrade: async () => summary(), + measureTokensGained: async () => 100, + measureTokensSold: async () => 100, +}); +let sequence = 0; +const rule = (kind: AutoRule['kind'] = 'stop_loss'): AutoRule => { + const id = `offline-rule-${++sequence}`; + const value: AutoRule = { + id, + mint: `${id}-mint`, + kind, + triggerPct: -30, + sellPercent: 50, + buySol: 0.05, + triggerPriceSol: 1, + enabled: true, + createdAt: 1, + }; + db.addRule(value); + return value; +}; +const plan = (): DcaPlan => { + const id = `offline-plan-${++sequence}`; + const value: DcaPlan = { + id, + mint: `${id}-mint`, + buySol: 0.05, + roundsDone: 2, + roundsTotal: 4, + intervalMinutes: 60, + nextRunAt: 0, + enabled: true, + createdAt: 1, + }; + db.addDcaPlan(value); + return value; +}; +let passed = 0; +const check = (name: string) => { + passed++; + console.log(` ✓ ${name}`); +}; + +try { + for (const kind of ['stop_loss', 'limit_buy'] as const) { + const value = rule(kind); + let trades = 0; + const services = baseServices(); + services.batchPumpTrade = async () => { trades++; return summary(); }; + await fire(value, 1, rejectNotify, services); + assert.equal(trades, 1); + assert.ok(value.firedAt, 'a notification failure must not re-arm a filled order'); + assert.equal(value.failedAttempts, undefined); + assert.ok(!db.activeRules().some((r) => r.id === value.id)); + check(`${kind}: filled order stays fired when Telegram fails`); + } + + { + const value = rule(); + const services = baseServices(); + services.measureTokensSold = async () => { throw new Error('Post-trade balance unavailable'); }; + await fire(value, 1, noopNotify, services); + assert.ok(value.firedAt); + assert.equal(value.failedAttempts, undefined); + check('post-fill processing failure does not repeat the trade'); + } + + { + const value = rule(); + let trades = 0; + const services = baseServices(); + services.getMintBalances = async () => { throw new Error('RPC unavailable'); }; + services.batchPumpTrade = async () => { trades++; return summary(); }; + await fire(value, 1, noopNotify, services); + assert.equal(trades, 0); + assert.equal(value.firedAt, undefined); + assert.equal(value.failedAttempts, 1); + check('unreadable exit balances preserve protection for a bounded retry'); + } + + { + const value = rule(); + const services = baseServices(); + services.getMintBalances = async () => new Map(); + await fire(value, 1, noopNotify, services); + assert.ok(value.firedAt); + assert.equal(value.failedAttempts, undefined); + check('confirmed empty holdings retire the exit rule'); + } + + for (const kind of ['stop_loss', 'limit_buy'] as const) { + const value = rule(kind); + const services = baseServices(); + services.batchPumpTrade = async () => rejected(); + await fire(value, 1, noopNotify, services); + assert.equal(value.firedAt, undefined); + assert.equal(value.failedAttempts, 1); + check(`${kind}: definitely rejected order is re-armed`); + } + + { + const value = rule(); + const services = baseServices(); + services.batchPumpTrade = async () => uncertain(); + const notices: string[] = []; + await fire(value, 1, async (text) => { notices.push(text); }, services); + assert.ok(value.firedAt); + assert.equal(value.failedAttempts, undefined); + assert.ok(notices.some((text) => text.includes('may still land'))); + check('unconfirmed submission is held for review instead of retried'); + } + + { + const value = rule(); + const services = baseServices(); + services.batchPumpTrade = async () => summary([result(), result({ ok: false, signature: undefined, error: 'Rejected' })]); + await fire(value, 1, noopNotify, services); + assert.ok(value.firedAt); + assert.equal(value.failedAttempts, undefined); + check('partial fills never replay the successful wallets'); + } + + { + const value = rule(); + const services = baseServices(); + services.batchPumpTrade = async () => { throw new Error('Execution interrupted'); }; + await fire(value, 1, noopNotify, services); + assert.ok(value.firedAt); + assert.equal(value.failedAttempts, undefined); + check('a thrown batch with unknown submission state is never blindly retried'); + } + + { + const value = plan(); + const services = baseServices(); + services.batchPumpTrade = async () => rejected(); + await runDueDca(noopNotify, services); + assert.equal(value.roundsDone, 2, 'rollback must use the count before the store mutated the plan'); + assert.ok(value.nextRunAt <= Date.now()); + db.removeDcaPlan(value.id); + check('DCA round with no fills returns its original round count'); + } + + { + const value = plan(); + const services = baseServices(); + services.batchPumpTrade = async () => uncertain(); + await runDueDca(noopNotify, services); + assert.equal(value.roundsDone, 3, 'an unconfirmed round remains claimed'); + assert.equal(value.enabled, false, 'future spending pauses until confirmation is checked'); + assert.ok(!db.dueDcaPlans().some((p) => p.id === value.id)); + db.removeDcaPlan(value.id); + check('unconfirmed DCA execution pauses the plan without retrying the round'); + } + + { + const value = plan(); + const services = baseServices(); + services.batchPumpTrade = async () => summary([result(), uncertain().results[0]!]); + await runDueDca(noopNotify, services); + assert.equal(value.roundsDone, 3); + assert.equal(value.enabled, false); + assert.equal(db.position(value.mint)?.buyFills, 1, 'confirmed fills still reach the ledger'); + db.removeDcaPlan(value.id); + check('partial DCA fills are recorded while an unconfirmed wallet pauses further spending'); + } + + { + const value = plan(); + const services = baseServices(); + services.batchPumpTrade = async () => { throw new Error('Batch interrupted'); }; + const notices: string[] = []; + await runDueDca(async (text) => { notices.push(text); }, services); + assert.equal(value.roundsDone, 3); + assert.equal(value.enabled, false); + assert.ok(notices.some((text) => text.includes('plan is paused'))); + db.removeDcaPlan(value.id); + check('a thrown DCA batch keeps its round claimed and reports the paused plan'); + } + + { + const mint = 'offline-copy-mint'; + db.recordBuy(mint, { solSpent: 0.4, fills: 1, tokensBought: 100, freshEntry: true }); + const priorPosition = structuredClone(db.position(mint)); + const target: CopyTarget = { + id: 'offline-copy-target', + address: 'offline-trader', + label: 'Offline trader', + buySol: 0.05, + sizeMode: 'fixed', + sizePercent: 5, + entryMode: 'first', + maxEntries: 1, + exitMode: 'all', + copiedMints: [], + enabled: true, + createdAt: 1, + }; + db.addCopyTarget(target); + let trades = 0; + const services: CopyBuyServices = { + selectWallets: () => [wallet], + getMintBalances: async () => { throw new Error('RPC unavailable'); }, + screenToken: async () => ({ verdict: { safe: true, reasons: [], notes: [] } }), + batchPumpTrade: async () => { trades++; return summary(); }, + measureTokensGained: async () => 100, + }; + await mirrorBuy(target, { mint, delta: 100, before: 0 }, 1, noopNotify, services); + assert.equal(trades, 0); + assert.equal(target.entryCounts?.[mint], undefined, 'a refused balance read must not consume an entry'); + assert.deepEqual(target.copiedMints, []); + assert.deepEqual(db.position(mint), priorPosition, 'existing basis and exposure remain intact'); + assert.match(db.copyDecisions()[0]!.reason, /balances could not be read/); + check('copy trades fail closed on unreadable holdings without resetting the cost basis'); + } + + console.log(`\n${passed} offline copy-trade and watcher regressions passed.`); +} finally { + fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/portfolio-regressions.ts b/scripts/portfolio-regressions.ts new file mode 100644 index 0000000..9c5d7b3 --- /dev/null +++ b/scripts/portfolio-regressions.ts @@ -0,0 +1,118 @@ +/** Offline regressions: partial reads must not become a full valuation. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Context } from 'grammy'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-portfolio-')); +process.env.BOT_TOKEN = '123:TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; +process.env.VAULT_AUTOLOCK_MINUTES = '0'; + +const { initVaultWithKeyfile, lockVault } = await import('../src/store/vault.js'); +const { generateSolanaWallet } = await import('../src/store/wallets.js'); +const { db } = await import('../src/store/db.js'); +const { rpc, WSOL_MINT } = await import('../src/chains/solana.js'); +const { clearPriceCache } = await import('../src/services/prices.js'); +const { buildPortfolio } = await import('../src/services/portfolio.js'); +const { showPnl, showPortfolio } = await import('../src/bot/handlers/core.js'); +const { renderPortfolio } = await import('../src/bot/ui.js'); +const { PublicKey } = await import('@solana/web3.js'); +const { TOKEN_PROGRAM_ID } = await import('@solana/spl-token'); + +const connection = rpc(); +const originalMultiple = connection.getMultipleAccountsInfo; +const originalTokens = connection.getParsedTokenAccountsByOwner; +const originalFetch = globalThis.fetch; +const mint = PublicKey.unique().toBase58(); +let failTokens = false; +let priceTokens = true; +let priceSol = true; +let passed = 0; +function ok(name: string): void { + passed++; + console.log(` ✓ ${name}`); +} + +try { + initVaultWithKeyfile(); + generateSolanaWallet('test wallet', ['one']); + connection.getMultipleAccountsInfo = async (keys) => keys.map(() => ({ + lamports: 1_000_000_000, owner: PublicKey.default, data: Buffer.alloc(0), executable: false, rentEpoch: 0, + })); + connection.getParsedTokenAccountsByOwner = async (_owner, filter) => { + if (failTokens) throw new Error('offline token read failure'); + const classic = 'programId' in filter && filter.programId.equals(TOKEN_PROGRAM_ID); + return { + context: { slot: 1 }, + value: classic ? [{ + pubkey: PublicKey.unique(), + account: { + lamports: 0, owner: TOKEN_PROGRAM_ID, executable: false, rentEpoch: 0, + data: { program: 'spl-token', space: 165, parsed: { info: { + mint, tokenAmount: { amount: '2000000', decimals: 6, uiAmount: 2 }, + } } }, + }, + }] : [], + }; + }; + globalThis.fetch = async () => new Response(JSON.stringify({ + ...(priceSol ? { [WSOL_MINT]: { usdPrice: 100 } } : {}), + ...(priceTokens ? { [mint]: { usdPrice: 5 } } : {}), + }), { status: 200 }); + + const complete = await buildPortfolio(); + assert.deepEqual(complete.errors, []); + assert.equal(complete.totals.grandTotalUsd, 110); + ok('complete holdings and prices produce a complete valuation'); + + failTokens = true; + const unreadable = await buildPortfolio(); + assert.match(unreadable.errors.join(' '), /Token balances for test wallet/); + assert.match(renderPortfolio(unreadable, null), /Known value only/); + ok('failed token balance reads surface as an incomplete portfolio'); + + let rendered = ''; + const ctx = { reply: async (text: string) => { rendered = text; } } as unknown as Context; + await showPnl(ctx); + assert.match(rendered, /Could not work out the P&L/); + assert.equal(db.valueMarks().length, 0); + ok('failed balance reads cannot turn into P&L losses or history marks'); + + failTokens = false; + priceTokens = false; + clearPriceCache(); + const unpriced = await buildPortfolio(); + assert.match(unpriced.errors.join(' '), /1 token price unavailable/); + await showPnl(ctx); + assert.equal(db.valueMarks().length, 0); + ok('missing token prices cannot turn into complete history marks'); + + priceTokens = true; + priceSol = false; + clearPriceCache(); + assert.match((await buildPortfolio()).errors.join(' '), /SOL price unavailable/); + ok('missing SOL pricing is explicitly incomplete'); + + priceSol = true; + clearPriceCache(); + db.recordBuy(mint, { solSpent: 0.1, tokensBought: 2, fills: 1 }); + db.updateSettings({ activeGroup: 'one' }); + await showPortfolio(ctx); + assert.doesNotMatch(rendered, /on .* traded/); + ok('a wallet group is not compared against the entire account cost basis'); + + await showPnl(ctx); + assert.equal(db.valueMarks().length, 1); + ok('a complete account still records its value'); +} finally { + connection.getMultipleAccountsInfo = originalMultiple; + connection.getParsedTokenAccountsByOwner = originalTokens; + globalThis.fetch = originalFetch; + lockVault(); + fs.rmSync(dataDir, { recursive: true, force: true }); +} + +console.log(`\n${passed} offline portfolio regressions passed.`); diff --git a/scripts/reconcile-regressions.ts b/scripts/reconcile-regressions.ts new file mode 100644 index 0000000..9a8b9f8 --- /dev/null +++ b/scripts/reconcile-regressions.ts @@ -0,0 +1,159 @@ +/** History reconciliation checks with in-memory RPC responses and no delays. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { ConfirmedSignatureInfo, ParsedTransactionWithMeta } from '@solana/web3.js'; +import type { ReconcileServices } from '../src/services/reconcile.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-reconcile-regressions-')); +process.env.BOT_TOKEN = '123:OFFLINE_TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; + +const { db } = await import('../src/store/db.js'); +const { proceedsByMint, rebuildRealised } = await import('../src/services/reconcile.js'); +const owner = '11111111111111111111111111111111'; +const mint = 'offline-reconciliation-mint'; +const sale = { + transaction: { message: { accountKeys: [{ pubkey: owner }] } }, + meta: { + err: null, + preTokenBalances: [{ mint, owner, uiTokenAmount: { uiAmount: 100 } }], + postTokenBalances: [{ mint, owner, uiTokenAmount: { uiAmount: 0 } }], + preBalances: [0], + postBalances: [100_000_000], + }, +} as unknown as ParsedTransactionWithMeta; +const page = (prefix: string, count: number, blockTime = 10_000): ConfirmedSignatureInfo[] => + Array.from({ length: count }, (_, i) => ({ + signature: `${prefix}-${i}`, + slot: 1, + err: null, + memo: null, + blockTime, + confirmationStatus: 'confirmed', + })); +const mocks = ( + pages: ConfirmedSignatureInfo[][], + parse: (signature: string) => ParsedTransactionWithMeta | null = () => sale, +): ReconcileServices => { + let nextPage = 0; + return { + rpc: () => ({ + getSignaturesForAddress: async (_address, options) => { + assert.equal(options?.limit, 100); + return pages[nextPage++] ?? []; + }, + getParsedTransactions: async (signatures) => signatures.map(parse), + }), + // Production still retries and spaces reads; tests execute only their mocks. + paced: async (fn) => fn(), + }; +}; +let passed = 0; +const check = (name: string) => { + passed++; + console.log(` ✓ ${name}`); +}; + +try { + { + const result = await proceedsByMint(owner, 9_000_000, undefined, mocks([])); + assert.equal(result.complete, true); + assert.equal(result.scanned, 0); + check('an empty history is complete'); + } + + { + const result = await proceedsByMint(owner, 9_000_000, undefined, mocks([page('full', 100)])); + assert.equal(result.complete, true); + assert.equal(result.scanned, 100); + assert.ok(result.found.get(mint)! > 9.99); + check('a full page followed by the end of history is complete'); + } + + { + const pages = Array.from({ length: 12 }, (_, i) => page(`cap-${i}`, 100)); + const result = await proceedsByMint(owner, 9_000_000, undefined, mocks(pages)); + assert.equal(result.scanned, 1200); + assert.equal(result.complete, false); + assert.ok(result.found.get(mint)! > 119.99, 'partial proceeds remain available'); + check('reaching the signature budget without the history boundary is incomplete'); + } + + { + const pages = Array.from({ length: 12 }, (_, i) => page(`boundary-${i}`, 100, i === 11 ? 8_000 : 10_000)); + const result = await proceedsByMint(owner, 9_000_000, undefined, mocks(pages)); + assert.equal(result.scanned, 1200); + assert.equal(result.complete, true, 'the history boundary was established at the budget boundary'); + check('the signature budget still permits a proven complete history boundary'); + } + + { + const services = mocks([page('missing', 3)], (signature) => { + if (signature.endsWith('-1')) return null; + if (signature.endsWith('-2')) return { ...sale, meta: null }; + return sale; + }); + const result = await proceedsByMint(owner, 9_000_000, undefined, services); + assert.equal(result.complete, false); + assert.equal(result.scanned, 1); + assert.equal(result.found.get(mint), 0.1); + check('null transactions and absent metadata report incomplete while preserving known sales'); + } + + { + const services = mocks([page('first', 100), page('last', 1)], (signature) => + signature === 'first-0' ? null : sale, + ); + const result = await proceedsByMint(owner, 9_000_000, undefined, services); + assert.equal(result.complete, false); + assert.equal(result.scanned, 100); + check('an unreadable earlier transaction stays incomplete after later pages finish'); + } + + { + const services = mocks([page('failed', 1)], () => ({ + ...sale, + meta: { ...sale.meta!, err: { InstructionError: [0, 'InvalidArgument'] } }, + })); + const result = await proceedsByMint(owner, 9_000_000, undefined, services); + assert.equal(result.complete, true, 'a known failed transaction is fully accounted for'); + assert.equal(result.found.size, 0); + check('known failed transactions do not make a scan incomplete'); + } + + { + const services = mocks([page('short-response', 2)]); + services.rpc = () => ({ + ...mocks([page('short-response', 2)]).rpc(), + getParsedTransactions: async () => [sale], + }); + const result = await proceedsByMint(owner, 9_000_000, undefined, services); + assert.equal(result.complete, false); + assert.equal(result.found.get(mint), 0.1); + check('a short parse response cannot claim complete coverage'); + } + + { + db.addWallet({ + id: 'offline-wallet', kind: 'solana', address: owner, label: 'Offline wallet', + secret: '', groups: [], isMain: false, disabled: false, createdAt: 1, + }); + db.recordBuy(mint, { solSpent: 0.2, fills: 1, tokensBought: 100 }); + const result = await rebuildRealised(undefined, mocks( + [page('repair', 2, Math.floor(Date.now() / 1000))], + (signature) => signature.endsWith('-0') ? sale : null, + )); + assert.equal(result.complete, false); + assert.equal(result.walletsRead, 1); + assert.equal(result.repaired.length, 1); + assert.equal(db.position(mint)?.realisedSol, 0.1); + check('partial scans repair measured proceeds and expose incompleteness in the final report'); + } + + console.log(`\n${passed} offline reconciliation regressions passed.`); +} finally { + fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 88c4feb..4839fef 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -132,7 +132,7 @@ removePassphrase(wallets.resealAll); assert.equal(vaultMode(), 'keyfile', 'the vault is now opened by its key file'); assert.deepEqual(wallets.allWallets().map((w) => w.address), beforeAddrs, 'no wallet lost or changed'); assert.equal(wallets.solanaKeypair(wallets.allWallets()[0]!).publicKey.toBase58(), beforeAddrs[0]); -ok('every key survives being re-sealed without a passphrase'); +ok('every key survives removing the passphrase'); // the whole point: a restart must not shut anyone out lockVault(); @@ -2047,7 +2047,7 @@ ok('a rule that eventually works stops carrying its failures'); * buy and never given back, so a plan configured for ten rounds could buy * seven and report itself finished. */ -assert.match(watcherSrc, /roundsDone: plan\.roundsDone, nextRunAt: Date\.now\(\)/, 'a failed round is returned'); +assert.match(watcherSrc, /roundsDone: previousRoundsDone, nextRunAt: Date\.now\(\)/, 'a failed round is returned'); ok('a DCA round that bought nothing is put back rather than spent'); console.log('\n[34] A copied entry does not wait like a card does'); @@ -2157,7 +2157,7 @@ console.log('\n[37] An exit reaches wherever the position is'); * The position is still there and no longer has a stop. */ const wSrc2 = fs.readFileSync('src/services/watcher.ts', 'utf8'); -assert.match(wSrc2, /buying \? selectWallets\(\) : selectWallets\(\{ group: null \}\)/, 'exits see every wallet'); +assert.match(wSrc2, /buying \? services\.selectWallets\(\) : services\.selectWallets\(\{ group: null \}\)/, 'exits see every wallet'); ok('a rule selling a position looks in every wallet, not the group in use'); // but an entry still respects the group, which is what a group is for @@ -2272,7 +2272,7 @@ console.log('\n[40] Nothing on the entry path waits for something it does not ne const ct = fs.readFileSync('src/services/copytrade.ts', 'utf8'); const body = ct.slice(ct.indexOf('async function mirrorBuyLocked'), ct.indexOf('async function mirrorSell')); assert.ok( - body.indexOf('const screening = screenToken') < body.indexOf('await getMintBalances'), + body.indexOf('const screening = services.screenToken') < body.indexOf('await services.getMintBalances'), 'the screen is started before the balances are awaited', ); assert.match(body, /const \{ verdict, info \} = await screening;/, 'and awaited only where it is needed'); diff --git a/scripts/transaction-regressions.ts b/scripts/transaction-regressions.ts new file mode 100644 index 0000000..28f59d1 --- /dev/null +++ b/scripts/transaction-regressions.ts @@ -0,0 +1,307 @@ +/** Offline regressions for transaction status handling. Never contacts an RPC. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import bs58 from 'bs58'; +import { ComputeBudgetInstruction, Keypair, SystemProgram, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; + +const temporaryData = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-transactions-')); + +process.env.BOT_TOKEN = '123:TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = temporaryData; +process.env.VAULT_AUTOLOCK_MINUTES = '0'; +process.env.SOLANA_RPC_URL = 'http://127.0.0.1:8899'; +process.env.SOLANA_SEND_RPC_URL = process.env.SOLANA_RPC_URL; + +const { getBundleStatus, sendBundle, waitForBundle } = await import('../src/trade/jito.js'); +const { TransactionRejectedError, TransactionSubmissionUnknownError } = await import('../src/trade/errors.js'); +const { rpc, sendAndConfirm, signatureLanded, priorityFeeInstructions } = await import('../src/chains/solana.js'); +const originalFetch = globalThis.fetch; +const client = rpc(); +const originalRpc = { + sendRawTransaction: client.sendRawTransaction, + getSignatureStatuses: client.getSignatureStatuses, + getMultipleAccountsInfo: client.getMultipleAccountsInfo, + getLatestBlockhash: client.getLatestBlockhash, + getParsedTokenAccountsByOwner: client.getParsedTokenAccountsByOwner, +}; +let closeVault = () => {}; +let passed = 0; + +function ok(name: string): void { + passed++; + console.log(` ✓ ${name}`); +} + +function transaction(signer: Keypair, blockhash = Keypair.generate().publicKey.toBase58()): VersionedTransaction { + const tx = new VersionedTransaction(new TransactionMessage({ + payerKey: signer.publicKey, + recentBlockhash: blockhash, + instructions: [SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: Keypair.generate().publicKey, lamports: 1 })], + }).compileToV0Message()); + tx.sign([signer]); + return tx; +} + +function confirmed(): ReturnType { + return Promise.resolve({ context: { slot: 1 }, value: [{ slot: 1, confirmations: 1, err: null, confirmationStatus: 'confirmed' }] }); +} + +async function statusResponse(response: unknown, expected: string, name: string): Promise { + let requests = 0; + globalThis.fetch = async (_input, init) => { + requests++; + const request = JSON.parse(String(init?.body)); + assert.equal(request.method, 'getBundleStatuses'); + assert.deepEqual(request.params, [['offline-bundle']]); + return new Response(JSON.stringify(response), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + assert.equal(await getBundleStatus('offline-bundle'), expected, name); + assert.equal(requests, 1, 'the status check uses only the mocked request'); + ok(name); +} + +function bundleEntry(confirmation_status: string, err: unknown): unknown { + return { + jsonrpc: '2.0', + id: 1, + result: { + context: { slot: 242806119 }, + value: [{ + bundle_id: 'offline-bundle', + transactions: ['offline-signature'], + slot: 242804011, + confirmation_status, + err, + }], + }, + }; +} + +try { + // Actual Jito wire format from its getBundleStatuses response example. + await statusResponse(bundleEntry('finalized', { Ok: null }), 'Landed', 'Jito { Ok: null } finalized bundle landed'); + await statusResponse(bundleEntry('confirmed', { Ok: null }), 'Landed', 'Jito { Ok: null } confirmed bundle landed'); + await statusResponse(bundleEntry('processed', { Ok: null }), 'Pending', 'processed bundle still awaits confirmation'); + await statusResponse(bundleEntry('confirmed', null), 'Landed', 'null error remains compatible'); + await statusResponse(bundleEntry('confirmed', { Err: { InstructionError: [1, { Custom: 6004 }] } }), 'Failed', 'explicit Jito Err is a failed bundle'); + await statusResponse(bundleEntry('finalized', { Ok: null, Err: 'failure' }), 'Failed', 'Err cannot be masked by an Ok field'); + await statusResponse({ jsonrpc: '2.0', id: 1, result: { value: [null] } }, 'Pending', 'missing bundle remains pending'); + await statusResponse({ jsonrpc: '2.0', id: 1, error: { code: -32005, message: 'unavailable' } }, 'Unknown', 'RPC error is unknown rather than a transaction failure'); + + globalThis.fetch = async () => { throw new Error('offline transport failure'); }; + assert.equal(await getBundleStatus('offline-bundle'), 'Unknown'); + ok('transport failure leaves the outcome unknown'); + + const tx = transaction(Keypair.generate()); + const expectedSignature = bs58.encode(tx.signatures[0]!); + let sends = 0; + client.sendRawTransaction = async () => { sends++; throw new Error('provider lost the send response'); }; + await assert.rejects(sendAndConfirm(tx), (err: unknown) => { + assert.ok(err instanceof TransactionSubmissionUnknownError); + assert.equal(err.signature, expectedSignature); + return true; + }); + assert.equal(sends, 1); + ok('lost send response preserves the original signature and remains ambiguous'); + + client.sendRawTransaction = async () => expectedSignature; + client.getSignatureStatuses = async () => { throw new Error('status provider unavailable'); }; + await assert.rejects(sendAndConfirm(tx), TransactionSubmissionUnknownError); + ok('confirmation RPC failure remains ambiguous'); + + await assert.rejects(sendAndConfirm(tx, { timeoutMs: 0 }), TransactionSubmissionUnknownError); + ok('confirmation timeout remains ambiguous'); + + client.getSignatureStatuses = async () => ({ + context: { slot: 1 }, + value: [{ slot: 1, confirmations: 1, err: { InstructionError: [0, { Custom: 6004 }] }, confirmationStatus: 'confirmed' }], + }); + await assert.rejects(sendAndConfirm(tx), (err: unknown) => { + assert.ok(err instanceof TransactionRejectedError); + assert.equal(err.signature, expectedSignature); + return true; + }); + ok('explicit chain rejection is a definite failure'); + + client.getSignatureStatuses = confirmed; + assert.equal(await sendAndConfirm(tx), expectedSignature); + ok('confirmed transaction returns its signed identity'); + + client.getSignatureStatuses = async () => ({ context: { slot: 1 }, value: [null] }); + assert.equal(await signatureLanded(expectedSignature), 'unknown'); + ok('an absent RPC status does not prove the transaction cannot land'); + + client.getSignatureStatuses = async () => ({ + context: { slot: 1 }, + value: [{ slot: 1, confirmations: 0, err: { InstructionError: [0, { Custom: 6004 }] }, confirmationStatus: 'processed' }], + }); + assert.equal(await signatureLanded(expectedSignature), 'unknown'); + await assert.rejects(sendAndConfirm(tx, { timeoutMs: 1 }), TransactionSubmissionUnknownError); + ok('processed error awaiting confirmation cannot authorize a second spend'); + + for (const units of [20_000, 200_000]) { + const fee = ComputeBudgetInstruction.decodeSetComputeUnitPrice(priorityFeeInstructions(0, units)[1]!); + assert.equal(fee.microLamports, 0n); + } + assert.throws(() => priorityFeeInstructions(-1), /non-negative/); + assert.throws(() => priorityFeeInstructions(Number.NaN), /finite/); + ok('zero priority fee charges exactly zero, including SOL sweep compute budgets'); + + globalThis.fetch = async () => { throw new Error('bundle response lost after dispatch'); }; + await assert.rejects(sendBundle([tx]), TransactionSubmissionUnknownError); + ok('lost bundle send response remains ambiguous'); + globalThis.fetch = async () => new Response(JSON.stringify({ error: { code: -32602, message: 'bundle rejected' } })); + await assert.rejects(sendBundle([tx]), TransactionRejectedError); + ok('explicit bundle rejection is a definite failure'); + globalThis.fetch = async () => new Response(JSON.stringify({ jsonrpc: '2.0', id: 1 })); + await assert.rejects(sendBundle([tx]), TransactionSubmissionUnknownError); + ok('missing bundle id leaves submission ambiguous'); + assert.equal(await waitForBundle('offline-bundle', 0), 'Pending'); + ok('bundle deadline preserves pending state'); + + const vault = await import('../src/store/vault.js'); + const wallets = await import('../src/store/wallets.js'); + const { db } = await import('../src/store/db.js'); + const { batchPumpTrade, batchSweepToken } = await import('../src/trade/engine.js'); + vault.initVaultWithKeyfile(); + closeVault = vault.lockVault; + const wallet = wallets.generateSolanaWallet('offline-transaction-wallet'); + const signer = wallets.solanaKeypair(wallet); + const request = { + action: 'buy' as const, + mint: Keypair.generate().publicKey.toBase58(), + amount: 0.01, + denominatedInSol: true, + slippagePercent: 5, + priorityFeeSol: 0.00005, + pool: 'pump' as const, + }; + db.updateSettings({ priorityFeeMode: 'fixed', executionMode: 'parallel' }); + client.getMultipleAccountsInfo = async (keys) => keys.map(() => ({ + data: Buffer.alloc(0), executable: false, lamports: 1e9, owner: SystemProgram.programId, rentEpoch: 0, + })); + let builds = 0; + let builtSignature = ''; + globalThis.fetch = async (input) => { + assert.equal(String(input), 'https://pumpportal.fun/api/trade-local'); + builds++; + const built = transaction(signer); + builtSignature = bs58.encode(built.signatures[0]!); + return new Response(built.serialize()); + }; + sends = 0; + client.sendRawTransaction = async () => { sends++; throw new Error('send response lost'); }; + const ambiguous = await batchPumpTrade([wallet], request, 'parallel'); + assert.equal(builds, 1); + assert.equal(sends, 1); + assert.equal(ambiguous.results[0]?.confirmationUnknown, true); + assert.equal(ambiguous.results[0]?.signature, builtSignature); + ok('PumpPortal ambiguous submission is not rebuilt and retains its signature'); + + builds = 0; + sends = 0; + client.sendRawTransaction = async (bytes) => { + sends++; + return bs58.encode(VersionedTransaction.deserialize(new Uint8Array(bytes)).signatures[0]!); + }; + let checks = 0; + client.getSignatureStatuses = async () => { + checks++; + if (checks === 1) { + return { context: { slot: 1 }, value: [{ slot: 1, confirmations: 1, err: { InstructionError: [0, { Custom: 6004 }] }, confirmationStatus: 'confirmed' }] }; + } + return confirmed(); + }; + const retried = await batchPumpTrade([wallet], request, 'parallel'); + assert.equal(builds, 2); + assert.equal(sends, 2); + assert.equal(retried.results[0]?.ok, true); + assert.equal(retried.results[0]?.confirmationUnknown, undefined); + ok('a definite chain rejection can be rebuilt and confirmed safely'); + + client.getSignatureStatuses = confirmed; + const progressFailure = await batchPumpTrade([wallet], request, 'parallel', async () => { throw new Error('Telegram unavailable'); }); + assert.equal(progressFailure.results[0]?.ok, true); + ok('progress notification failure does not change a confirmed trade result'); + + globalThis.fetch = async (input, init) => { + const url = String(input); + if (url === 'https://pumpportal.fun/api/trade-local') return new Response('unavailable', { status: 400 }); + if (url.startsWith('https://lite-api.jup.ag/swap/v1/quote?')) { + return new Response(JSON.stringify({ outAmount: '100', inAmount: '10000000', routePlan: [] })); + } + if (url === 'https://lite-api.jup.ag/swap/v1/swap') { + assert.ok(JSON.parse(String(init?.body)).userPublicKey === wallet.address); + return new Response(JSON.stringify({ swapTransaction: Buffer.from(transaction(signer).serialize()).toString('base64') })); + } + throw new Error(`Unexpected offline request: ${url}`); + }; + sends = 0; + client.sendRawTransaction = async () => { sends++; throw new Error('Jupiter send response lost'); }; + const jupiterAmbiguous = await batchPumpTrade([wallet], request, 'parallel'); + assert.equal(sends, 1); + assert.equal(jupiterAmbiguous.results[0]?.confirmationUnknown, true); + assert.ok(jupiterAmbiguous.results[0]?.signature); + ok('Jupiter fallback preserves uncertain submission state'); + + globalThis.fetch = async (input, init) => { + if (String(input) === 'https://pumpportal.fun/api/trade-local') { + assert.ok(Array.isArray(JSON.parse(String(init?.body)))); + return new Response(JSON.stringify([bs58.encode(transaction(signer).serialize())])); + } + if (String(input) === 'https://mainnet.block-engine.jito.wtf/api/v1/bundles') throw new Error('Jito send response lost'); + throw new Error(`Unexpected offline request: ${String(input)}`); + }; + const bundleAmbiguous = await batchPumpTrade([wallet], request, 'bundle'); + assert.equal(bundleAmbiguous.results[0]?.confirmationUnknown, true); + assert.ok(bundleAmbiguous.results[0]?.signature); + ok('Jito batch preserves each uncertain transaction signature'); + + const sourceAccount = Keypair.generate().publicKey; + const destination = Keypair.generate().publicKey.toBase58(); + client.getParsedTokenAccountsByOwner = async (_owner, filter) => ({ + context: { slot: 1 }, + value: 'programId' in filter && filter.programId.equals(TOKEN_PROGRAM_ID) ? [{ + pubkey: sourceAccount, + account: { + executable: false, + lamports: 2_039_280, + owner: TOKEN_PROGRAM_ID, + rentEpoch: 0, + data: { + program: 'spl-token', space: 165, + parsed: { info: { mint: request.mint, tokenAmount: { amount: '123000000', decimals: 6, uiAmount: 123 } } }, + }, + }, + }] : [], + }); + client.getLatestBlockhash = async () => ({ blockhash: Keypair.generate().publicKey.toBase58(), lastValidBlockHeight: 100 }); + client.getSignatureStatuses = confirmed; + let sweepTx: VersionedTransaction | undefined; + client.sendRawTransaction = async (bytes) => { + sweepTx = VersionedTransaction.deserialize(new Uint8Array(bytes)); + return bs58.encode(sweepTx.signatures[0]!); + }; + const swept = await batchSweepToken([wallet], request.mint, destination); + assert.equal(swept.results[0]?.ok, true); + assert.ok(sweepTx); + const transfer = sweepTx.message.compiledInstructions[3]!; + const close = sweepTx.message.compiledInstructions[4]!; + assert.equal(sweepTx.message.staticAccountKeys[transfer.accountKeyIndexes[0]!]!.toBase58(), sourceAccount.toBase58()); + assert.equal(sweepTx.message.staticAccountKeys[close.accountKeyIndexes[0]!]!.toBase58(), sourceAccount.toBase58()); + ok('token sweep transfers and closes the actual non-associated holding account'); +} finally { + globalThis.fetch = originalFetch; + Object.assign(client, originalRpc); + closeVault(); + fs.rmSync(temporaryData, { recursive: true, force: true }); +} + +console.log(`\n${passed} offline transaction regressions passed.`); diff --git a/scripts/wallet-regressions.ts b/scripts/wallet-regressions.ts new file mode 100644 index 0000000..9a5815b --- /dev/null +++ b/scripts/wallet-regressions.ts @@ -0,0 +1,246 @@ +/** Offline wallet safety regressions. All data and Telegram calls are isolated. */ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Update } from 'grammy/types'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-wallet-regressions-')); +process.env.BOT_TOKEN = '123:OFFLINE'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; +process.env.VAULT_AUTOLOCK_MINUTES = '0'; + +// Any accidental network request must fail the regression instead of reaching +// Telegram, an RPC endpoint or a trading service. +const originalFetch = globalThis.fetch; +globalThis.fetch = async () => { throw new Error('Network is disabled in wallet regressions.'); }; + +const vault = await import('../src/store/vault.js'); +const { db, flush } = await import('../src/store/db.js'); +const wallets = await import('../src/store/wallets.js'); +const state = await import('../src/bot/session.js'); +const { createBot } = await import('../src/bot/index.js'); + +try { + await vault.initVault('offline regression passphrase'); + const first = wallets.generateSolanaWallet('first'); + const second = wallets.generateSolanaWallet('second'); + const originalSecrets = wallets.allWallets().map((w) => w.secret); + + // A failed later decryption must leave earlier records and disk unchanged. + second.secret = 'invalid ciphertext'; + flush(); + const beforeFailedReseal = fs.readFileSync(path.join(dataDir, 'wallets.json'), 'utf8'); + assert.throws(() => wallets.resealAll(vault.decryptSecret, (plain) => `replacement:${plain}`)); + assert.equal(first.secret, originalSecrets[0]); + assert.equal(second.secret, 'invalid ciphertext'); + flush(); + assert.equal(fs.readFileSync(path.join(dataDir, 'wallets.json'), 'utf8'), beforeFailedReseal); + second.secret = originalSecrets[1]!; + flush(); + + db.raw().mnemonic = 'invalid mnemonic ciphertext'; + assert.throws(() => wallets.resealAll(vault.decryptSecret, (plain) => `replacement:${plain}`)); + assert.deepEqual(wallets.allWallets().map((w) => w.secret), originalSecrets); + delete db.raw().mnemonic; + flush(); + + // Inject an interruption at each atomic rename in the conversion. The old + // passphrase and unchanged wallet ciphertext must still recover the vault. + const originalRename = fs.renameSync; + for (const failedFile of ['vault.key', 'vault.json']) { + const beforeWallets = fs.readFileSync(path.join(dataDir, 'wallets.json'), 'utf8'); + const beforeBackup = fs.readFileSync(path.join(dataDir, 'wallets.json.bak'), 'utf8'); + fs.renameSync = ((source, destination) => { + if (destination.toString() === path.join(dataDir, failedFile)) { + throw new Error(`Injected ${failedFile} write failure`); + } + return originalRename(source, destination); + }) as typeof fs.renameSync; + try { + assert.throws(() => vault.removePassphrase(wallets.resealAll), /Injected/); + } finally { + fs.renameSync = originalRename; + } + assert.equal(fs.existsSync(path.join(dataDir, `${failedFile}.${process.pid}.tmp`)), false, + 'a failed atomic write leaves no extra copy of its sensitive contents'); + assert.equal(vault.vaultMode(), 'passphrase'); + assert.equal(fs.readFileSync(path.join(dataDir, 'wallets.json'), 'utf8'), beforeWallets); + assert.equal(fs.readFileSync(path.join(dataDir, 'wallets.json.bak'), 'utf8'), beforeBackup); + vault.lockVault(); + await vault.unlockVault('offline regression passphrase'); + assert.equal(wallets.solanaKeypair(first).publicKey.toBase58(), first.address); + assert.equal(wallets.solanaKeypair(second).publicKey.toBase58(), second.address); + } + vault.removePassphrase(() => { throw new Error('Conversion must not rewrite wallet ciphertext.'); }); + assert.equal(vault.vaultMode(), 'keyfile'); + assert.deepEqual(wallets.allWallets().map((w) => w.secret), originalSecrets); + vault.lockVault(); + assert.equal(vault.unlockFromKeyfile(), true); + assert.equal(wallets.solanaKeypair(first).publicKey.toBase58(), first.address); + await assert.rejects(() => vault.unlockVault('offline regression passphrase'), /no passphrase/); + + // Solana's base58 alphabet is case-sensitive. Lookup must distinguish even + // addresses that differ only in case, and imports must not reject one as the + // other. Use a synthetic existing record so no matching private key is needed. + const secret = wallets.exportSecret(second); + const actualAddress = second.address; + second.address = actualAddress.replace(/[A-HJ-KM-NP-Za-hj-km-np-z]/, (char) => + char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase()); + assert.notEqual(second.address, actualAddress); + assert.equal(wallets.walletByAddress(second.address)?.id, second.id); + assert.equal(wallets.walletByAddress(actualAddress), undefined); + const imported = wallets.importPrivateKey(secret, 'case-sensitive import'); + assert.equal(imported.address, actualAddress); + assert.equal(wallets.walletByAddress(actualAddress)?.id, imported.id); + wallets.removeWallet(second.id); + + // Expiry is enforced when tapped, even if no new confirmation was staged. + const expiredId = state.stageConfirmation(1, 'expired', async () => {}); + state.session(1).confirmations.get(expiredId)!.createdAt -= 5 * 60_000 + 1; + assert.equal(state.takeConfirmation(1, expiredId), undefined); + const freshId = state.stageConfirmation(1, 'fresh', async () => {}); + assert.equal(state.takeConfirmation(1, freshId)?.label, 'fresh'); + assert.equal(state.takeConfirmation(1, freshId), undefined, 'confirmations are consumed once'); + + // Force collisions deterministically. Existing button IDs must keep their + // original target, including the comparatively small wallet-ID registry. + const originalRandom = crypto.randomBytes; + const withRandom = (value: number, run: () => T): T => { + crypto.randomBytes = ((size: number) => Buffer.alloc(size, value)) as typeof crypto.randomBytes; + try { return run(); } finally { crypto.randomBytes = originalRandom; } + }; + const withCollision = (run: () => T): T => { + let attempt = 0; + crypto.randomBytes = ((size: number) => Buffer.alloc(size, attempt++ === 0 ? 0 : 1)) as typeof crypto.randomBytes; + try { return run(); } finally { crypto.randomBytes = originalRandom; } + }; + const tokenA = withRandom(0, () => state.tokenId('token-a')); + const tokenB = withCollision(() => state.tokenId('token-b')); + assert.notEqual(tokenA, tokenB); + assert.equal(state.mintFromId(tokenA), 'token-a'); + assert.equal(state.mintFromId(tokenB), 'token-b'); + const walletA = withRandom(0, () => state.shortWalletId('wallet-a')); + const walletB = withCollision(() => state.shortWalletId('wallet-b')); + assert.notEqual(walletA, walletB); + assert.equal(state.walletFromShortId(walletA), 'wallet-a'); + assert.equal(state.walletFromShortId(walletB), 'wallet-b'); + const confirmA = withRandom(0, () => state.stageConfirmation(2, 'action-a', async () => {})); + const confirmB = withCollision(() => state.stageConfirmation(2, 'action-b', async () => {})); + assert.notEqual(confirmA, confirmB); + assert.equal(state.takeConfirmation(2, confirmA)?.label, 'action-a'); + assert.equal(state.takeConfirmation(2, confirmB)?.label, 'action-b'); + + // Exercise the real middleware and export route using a fake Telegram API. + const bot = createBot(); + bot.botInfo = { id: 123, is_bot: true, first_name: 'Offline', username: 'offline_bot', + can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false, + can_connect_to_business: false, has_main_web_app: false, has_topics_enabled: false, + allows_users_to_create_topics: false, can_manage_bots: false, supports_join_request_queries: false }; + const calls: { method: string; payload: Record }[] = []; + bot.api.config.use(async (_previous, method, payload) => { + const request = payload as Record; + calls.push({ method, payload: request }); + const result = method === 'sendMessage' || method === 'editMessageText' + ? { message_id: 10, date: 0, chat: { id: request.chat_id, type: 'private' }, text: request.text } + : true; + return { ok: true, result } as never; + }); + let updateId = 0; + const message = (userId: number, privateChat: boolean, text: string): Update => ({ + update_id: ++updateId, + message: { message_id: updateId, date: 0, from: { id: userId, is_bot: false, first_name: 'User' }, + chat: privateChat ? { id: userId, type: 'private', first_name: 'User' } : { id: -1, type: 'group', title: 'Group' }, + text, entities: [{ offset: 0, length: text.length, type: 'bot_command' }] }, + }); + await bot.handleUpdate(message(2, true, '/help')); + await bot.handleUpdate(message(1, false, '/help')); + assert.equal(calls.length, 0, 'unauthorised users and group chats never reach handlers'); + await bot.handleUpdate(message(1, true, '/help')); + assert.equal(calls.length, 1, 'owner commands still work in private chat'); + calls.length = 0; + const exportCallback = (privateChat: boolean): Update => ({ + update_id: ++updateId, + callback_query: { id: `query-${updateId}`, chat_instance: 'offline', + from: { id: 1, is_bot: false, first_name: 'Owner' }, + data: `export:${state.shortWalletId(first.id)}`, + message: { message_id: 11, date: 0, + chat: privateChat ? { id: 1, type: 'private', first_name: 'Owner' } : { id: -1, type: 'group', title: 'Group' } } }, + }); + await bot.handleUpdate(exportCallback(false)); + assert.equal(calls.length, 0, 'a group callback cannot disclose a wallet key'); + await bot.handleUpdate(exportCallback(true)); + assert.equal(calls.some(({ method, payload }) => + method === 'sendMessage' && String(payload.text).includes(wallets.exportSecret(first))), true, + 'owner key exports still work in private chat'); + + // A missing primary wallet document must recover existing wallets instead + // of treating the installation as new and overwriting the surviving backup. + const walletPath = path.join(dataDir, 'wallets.json'); + const walletDocument = fs.readFileSync(walletPath, 'utf8'); + const expectedAddresses = wallets.allWallets().map((w) => w.address); + fs.rmSync(walletPath); + db.reload(); + assert.deepEqual(wallets.allWallets().map((w) => w.address), expectedAddresses); + flush(); + assert.equal(wallets.solanaKeypair(wallets.walletById(first.id)!).publicKey.toBase58(), first.address); + fs.writeFileSync(walletPath, '{}'); + db.reload(); + assert.deepEqual(wallets.allWallets().map((w) => w.address), expectedAddresses, + 'structurally invalid primary documents also fall back to the validated backup'); + fs.rmSync(walletPath); + fs.writeFileSync(`${walletPath}.bak`, '{}'); + db.reload(); + assert.throws(() => wallets.allWallets(), 'an invalid backup must not be mistaken for an empty store'); + assert.equal(fs.existsSync(walletPath), false, 'failed recovery writes no new primary'); + assert.equal(fs.readFileSync(`${walletPath}.bak`, 'utf8'), '{}'); + fs.writeFileSync(walletPath, walletDocument); + fs.writeFileSync(`${walletPath}.bak`, walletDocument); + db.reload(); + + // Recover metadata independently without ever replacing the master key. + const metadataPath = path.join(dataDir, 'vault.json'); + const keyPath = path.join(dataDir, 'vault.key'); + const metadata = fs.readFileSync(metadataPath, 'utf8'); + const keyFile = fs.readFileSync(keyPath, 'utf8'); + vault.lockVault(); + fs.rmSync(metadataPath); + assert.equal(vault.openAtBoot(true), 'opened'); + assert.equal(fs.readFileSync(keyPath, 'utf8'), keyFile); + assert.equal(wallets.solanaKeypair(wallets.walletById(first.id)!).publicKey.toBase58(), first.address); + vault.lockVault(); + fs.writeFileSync(metadataPath, '{}'); + assert.equal(vault.openAtBoot(true), 'opened', 'invalid primary metadata recovers from backup'); + vault.lockVault(); + fs.rmSync(metadataPath); + fs.rmSync(`${metadataPath}.bak`); + assert.throws(() => vault.openAtBoot(true), /metadata is missing/); + assert.equal(fs.existsSync(metadataPath), false); + assert.equal(fs.readFileSync(keyPath, 'utf8'), keyFile, 'missing metadata must not overwrite the old key'); + fs.writeFileSync(`${metadataPath}.bak`, '{}'); + assert.throws(() => vault.openAtBoot(true), 'invalid backup metadata must not create a replacement vault'); + assert.equal(fs.existsSync(metadataPath), false); + assert.equal(fs.readFileSync(keyPath, 'utf8'), keyFile); + fs.writeFileSync(metadataPath, metadata); + fs.writeFileSync(`${metadataPath}.bak`, metadata); + assert.equal(vault.unlockFromKeyfile(), true); + + // A deliberate reset removes recovery copies as well, preventing an old + // wallet document from coming back under a newly generated encryption key. + vault.destroyVault(); + db.wipe(); + for (const file of [walletPath, metadataPath, keyPath]) { + for (const suffix of ['', '.bak', '.corrupt']) assert.equal(fs.existsSync(`${file}${suffix}`), false); + } + db.reload(); + assert.deepEqual(wallets.allWallets(), []); + assert.equal(vault.openAtBoot(false), 'created'); + + console.log('Wallet regressions passed: private-chat auth, confirmation expiry, collision-safe IDs, case-sensitive addresses, conversion recovery, validated backups and all-or-nothing resealing.'); +} finally { + vault.lockVault(); + globalThis.fetch = originalFetch; + fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/src/bot/handlers/core.ts b/src/bot/handlers/core.ts index 9ff4e2e..bb6f378 100644 --- a/src/bot/handlers/core.ts +++ b/src/bot/handlers/core.ts @@ -71,7 +71,7 @@ export async function showHome(ctx: Context): Promise { '', 'This vault was made before passphrases were removed, and its keys are still sealed under yours.', '', - 'Send it now. Everything gets re-sealed with a key the bot keeps itself, and you will never be asked again.', + 'Send it now. The verified key is saved so the bot can open your wallets after a restart, and you will never be asked again.', ].join('\n'), { parse_mode: 'HTML' }, ); @@ -137,10 +137,12 @@ export async function showPortfolio(ctx: Context): Promise { try { const portfolio = await buildPortfolio({ group: settings.activeGroup, includeTokens: true }); - // marked from the wallets on screen, so the line agrees with the number - // above it; the dedicated screen always reads the whole account + // The ledger is account-wide. A filtered or unreadable set of holdings + // cannot be compared to its full cost without inventing losses. const held = openValueSol(portfolio); - const pnl = accountPnl(db.positions(), held.marks, portfolio.totals.solPriceUsd, held.unpriced); + const pnl = settings.activeGroup === null && portfolio.errors.length === 0 + ? accountPnl(db.positions(), held.marks, portfolio.totals.solPriceUsd, held.unpriced) + : undefined; await render( ctx, @@ -252,6 +254,9 @@ export async function showPnl(ctx: Context): Promise { try { const portfolio = await buildPortfolio({ group: null, includeTokens: true }); + if (portfolio.errors.length > 0) { + throw new Error(`Incomplete portfolio: ${portfolio.errors.slice(0, 3).join(' | ')} Try refreshing when balances and prices are available.`); + } const held = openValueSol(portfolio); const pnl = accountPnl(db.positions(), held.marks, portfolio.totals.solPriceUsd, held.unpriced); diff --git a/src/bot/index.ts b/src/bot/index.ts index 01a4906..762033c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -44,6 +44,10 @@ export function createBot(): Bot { if (id) log.warn(`Ignored update from unauthorised user ${id}`); return; // no reply — an unauthorised caller learns nothing, not even that the bot is alive } + // Wallet keys, seed phrases and imported secrets belong only in the + // operator's direct chat. The same owner can also message this bot from a + // group, where replying would disclose those secrets to every member. + if (ctx.chat?.type !== 'private') return; await next(); }); @@ -843,7 +847,7 @@ const LEGACY_PASSPHRASE_PROMPT = [ '', 'This vault was made before passphrases were removed, and its keys are still sealed under yours.', '', - 'Send it now. The bot re-seals everything with a key it keeps itself and never asks again.', + 'Send it now. The bot saves the verified key for future restarts and never asks again.', ].join('\n'); /** Remove a message the operator sent that contained a secret. */ diff --git a/src/bot/session.ts b/src/bot/session.ts index 38893b4..0e454ec 100644 --- a/src/bot/session.ts +++ b/src/bot/session.ts @@ -57,6 +57,15 @@ export interface ConfirmAction { const sessions = new Map(); +/** Never overwrite the action or address referenced by an existing button. */ +function freshId(existing: ReadonlyMap, bytes: number): string { + let id: string; + do { + id = crypto.randomBytes(bytes).toString('hex'); + } while (existing.has(id)); + return id; +} + export function session(userId: number): SessionState { let s = sessions.get(userId); if (!s) { @@ -75,6 +84,7 @@ export function session(userId: number): SessionState { * that a forgotten prompt is gone before the next thing is typed. */ const PENDING_TTL_MS = 5 * 60_000; +const CONFIRMATION_TTL_MS = 5 * 60_000; export function setPending(userId: number, pending: PendingInput | undefined): void { const s = session(userId); @@ -128,10 +138,10 @@ export function stageConfirmation( run: (ctx: Context) => Promise, ): string { const s = session(userId); - const id = crypto.randomBytes(4).toString('hex'); + const id = freshId(s.confirmations, 4); // drop anything the operator walked away from - const cutoff = Date.now() - 5 * 60_000; + const cutoff = Date.now() - CONFIRMATION_TTL_MS; for (const [key, action] of s.confirmations) { if (action.createdAt < cutoff) s.confirmations.delete(key); } @@ -144,6 +154,9 @@ export function takeConfirmation(userId: number, id: string): ConfirmAction | un const s = session(userId); const action = s.confirmations.get(id); if (action) s.confirmations.delete(id); + // Check at the moment of use as well as when staging another action: the + // operator may return to an old button without creating a newer prompt. + if (!action || Date.now() - action.createdAt > CONFIRMATION_TTL_MS) return undefined; return action; } @@ -170,7 +183,7 @@ export function tokenId(mint: string): string { const existing = idsByToken.get(mint); if (existing) return existing; - const id = crypto.randomBytes(4).toString('hex'); + const id = freshId(tokenIds, 4); tokenIds.set(id, mint); idsByToken.set(mint, id); @@ -191,7 +204,7 @@ const idsByWallet = new Map(); export function shortWalletId(walletId: string): string { const existing = idsByWallet.get(walletId); if (existing) return existing; - const id = crypto.randomBytes(3).toString('hex'); + const id = freshId(walletIds, 3); walletIds.set(id, walletId); idsByWallet.set(walletId, id); evictOldest(walletIds, idsByWallet, 2000); diff --git a/src/bot/ui.ts b/src/bot/ui.ts index 23817b8..e908797 100644 --- a/src/bot/ui.ts +++ b/src/bot/ui.ts @@ -65,6 +65,7 @@ export function renderPortfolio(p: Portfolio, group: string | null, pnl?: Accoun // the one number the screen exists to show, given the room to be seen lines.push(`${fmtUsd(p.totals.grandTotalUsd)}`); + if (p.errors.length > 0) lines.push('Known value only — some balances or prices are unavailable.'); const split = [ `${fmtAmount(p.totals.solTotal, 4)} ◎`, p.totals.tokenUsd > 0 ? `${fmtUsd(p.totals.tokenUsd)} in tokens` : '', diff --git a/src/chains/solana.ts b/src/chains/solana.ts index 582c666..77624a9 100644 --- a/src/chains/solana.ts +++ b/src/chains/solana.ts @@ -18,8 +18,10 @@ import { createCloseAccountInstruction, } from '@solana/spl-token'; import { config } from '../config.js'; +import bs58 from 'bs58'; import { retry } from '../util.js'; import type { TokenBalance } from '../types.js'; +import { TransactionRejectedError, TransactionSubmissionUnknownError } from '../trade/errors.js'; export const LAMPORTS = LAMPORTS_PER_SOL; export const WSOL_MINT = 'So11111111111111111111111111111111111111112'; @@ -219,9 +221,10 @@ export async function getMintBalances(addresses: string[], mint: string): Promis // ── transaction plumbing ────────────────────────────────────────────────────── export function priorityFeeInstructions(priorityFeeSol: number, computeUnits = 200_000): TransactionInstruction[] { + if (!Number.isFinite(priorityFeeSol) || priorityFeeSol < 0) throw new Error('Priority fee must be a non-negative finite amount.'); const lamports = Math.floor(priorityFeeSol * LAMPORTS); // microLamports per compute unit, derived from the total SOL the user is willing to tip - const microLamportsPerCu = Math.max(1, Math.floor((lamports * 1_000_000) / computeUnits)); + const microLamportsPerCu = Math.max(0, Math.floor((lamports * 1_000_000) / computeUnits)); return [ ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: microLamportsPerCu }), @@ -291,12 +294,22 @@ export async function sendAndConfirm( tx: VersionedTransaction, opts: { skipPreflight?: boolean; timeoutMs?: number } = {}, ): Promise { - const signature = await sendRpc().sendRawTransaction(tx.serialize(), { - skipPreflight: opts.skipPreflight ?? true, - maxRetries: 3, - }); - await confirmSignature(signature, opts.timeoutMs ?? 60_000); - return signature; + // Serialize before dispatch, where a malformed transaction is still a definite + // local failure. The signed transaction gives us its identity even if the + // provider accepts it and then loses the response. + const bytes = tx.serialize(); + const signature = bs58.encode(tx.signatures[0]!); + try { + await sendRpc().sendRawTransaction(bytes, { + skipPreflight: opts.skipPreflight ?? true, + maxRetries: 3, + }); + await confirmSignature(signature, opts.timeoutMs ?? 60_000); + return signature; + } catch (err) { + if (err instanceof TransactionRejectedError) throw err; + throw new TransactionSubmissionUnknownError(signature, err); + } } /** @@ -317,11 +330,11 @@ export async function signatureLanded(signature: string): Promise rpc().getSignatureStatuses([signature]), { attempts: 3 }); const status = value[0]; - if (!status) return 'missing'; - if (status.err) return 'missing'; - return status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized' - ? 'landed' - : 'unknown'; + // Not visible to this RPC is not proof of failure: the transaction can still + // be queued at another provider while its blockhash remains valid. + if (!status) return 'unknown'; + if (status.confirmationStatus !== 'confirmed' && status.confirmationStatus !== 'finalized') return 'unknown'; + return status.err ? 'missing' : 'landed'; } catch { return 'unknown'; } @@ -338,9 +351,9 @@ export async function confirmSignature(signature: string, timeoutMs = 60_000): P const { value } = await rpc().getSignatureStatuses([signature]); const status = value[0]; - if (status) { - if (status.err) throw new Error(explainChainError(status.err)); - if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') return; + if (status && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized')) { + if (status.err) throw new TransactionRejectedError(explainChainError(status.err), signature); + return; } await new Promise((r) => setTimeout(r, 1500)); @@ -450,12 +463,15 @@ export async function sendSplToken( priorityFeeSol: number, programId = TOKEN_PROGRAM_ID.toBase58(), closeAccountAfter = false, + sourceTokenAccount?: string, ): Promise { const mintKey = new PublicKey(mint); const destOwner = new PublicKey(to); const program = new PublicKey(programId); - const source = getAssociatedTokenAddressSync(mintKey, from.publicKey, true, program); + const source = sourceTokenAccount + ? new PublicKey(sourceTokenAccount) + : getAssociatedTokenAddressSync(mintKey, from.publicKey, true, program); const dest = getAssociatedTokenAddressSync(mintKey, destOwner, true, program); const ixs: TransactionInstruction[] = [ diff --git a/src/services/copytrade.ts b/src/services/copytrade.ts index 8054515..8193fc1 100644 --- a/src/services/copytrade.ts +++ b/src/services/copytrade.ts @@ -800,14 +800,31 @@ function entriesSoFar(target: CopyTarget, mint: string): number { return target.copiedMints.includes(mint) ? 1 : 0; } -async function mirrorBuy( +export interface CopyBuyServices { + selectWallets: typeof selectWallets; + getMintBalances: typeof getMintBalances; + screenToken: typeof screenToken; + batchPumpTrade: typeof batchPumpTrade; + measureTokensGained: typeof measureTokensGained; +} + +const buyServices: CopyBuyServices = { + selectWallets, + getMintBalances, + screenToken, + batchPumpTrade, + measureTokensGained, +}; + +export async function mirrorBuy( target: CopyTarget, move: TokenMove, theirSol: number, notify: Notifier, + services: CopyBuyServices = buyServices, ): Promise { // every decision below reads state that a concurrent copy would change - return withMintLock(move.mint, () => mirrorBuyLocked(target, move, theirSol, notify)); + return withMintLock(move.mint, () => mirrorBuyLocked(target, move, theirSol, notify, services)); } async function mirrorBuyLocked( @@ -815,6 +832,7 @@ async function mirrorBuyLocked( move: TokenMove, theirSol: number, notify: Notifier, + services: CopyBuyServices, ): Promise { // a token this target was already refused is not reconsidered: the answer // will not have changed, and re-reading it turns one bad coin into a stream @@ -840,7 +858,7 @@ async function mirrorBuyLocked( return; } - const wallets = selectWallets(); + const wallets = services.selectWallets(); if (wallets.length === 0) return; const perWallet = copyBuySol(target, theirSol, wallets.length, config.safety.maxBuySolPerWallet); @@ -888,7 +906,7 @@ async function mirrorBuyLocked( * An unhandled rejection ends the process on this runtime, which would turn * a token that could not be read into the whole bot going down. */ - const screening = screenToken(move.mint).catch( + const screening = services.screenToken(move.mint).catch( (err: unknown): { verdict: SafetyVerdict; info?: TokenInfo } => ({ verdict: { safe: false, @@ -898,10 +916,17 @@ async function mirrorBuyLocked( }), ); - const heldBefore = await getMintBalances(wallets.map((w) => w.address), move.mint).catch( + const heldBefore = await services.getMintBalances(wallets.map((w) => w.address), move.mint).catch( () => undefined, ); - const holding = heldBefore !== undefined && [...heldBefore.values()].some((v) => v > 0n); + if (heldBefore === undefined) { + // Unknown holdings cannot establish room under either position limit, and + // must not reset the cost basis as though this were an empty position. + log.warn(`Skipped copying ${target.label} into ${move.mint}: holdings could not be read.`); + noted(target, move.mint, 'Your token balances could not be read — exposure limits cannot be checked'); + return; + } + const holding = [...heldBefore.values()].some((v) => v > 0n); const openSol = openExposureSol(move.mint, holding); /* @@ -1061,7 +1086,7 @@ async function mirrorBuyLocked( ).catch(() => {}); try { - const summary = await batchPumpTrade(wallets, { + const summary = await services.batchPumpTrade(wallets, { action: 'buy', mint: move.mint, amount: perWallet, @@ -1075,7 +1100,7 @@ async function mirrorBuyLocked( // the token count is the cost basis: without it there is no entry price, // and without an entry price a take-profit or stop-loss cannot fire at all - const tokensGained = await measureTokensGained(addresses, move.mint, heldBefore, info?.decimals); + const tokensGained = await services.measureTokensGained(addresses, move.mint, heldBefore, info?.decimals); db.recordBuy(move.mint, { solSpent: perWallet * fills, fills, diff --git a/src/services/portfolio.ts b/src/services/portfolio.ts index 8bfa56f..f1d1e61 100644 --- a/src/services/portfolio.ts +++ b/src/services/portfolio.ts @@ -101,6 +101,7 @@ async function loadSolana( b.tokens = await getSplBalances(b.address); } catch (err) { b.error = errMessage(err); + errors.push(`Token balances for ${b.label}: ${b.error}`); } }); @@ -121,6 +122,7 @@ async function computeTotals( } catch (err) { errors.push(`SOL price: ${errMessage(err)}`); } + if (solPrice <= 0) errors.push('SOL price unavailable; USD totals are incomplete.'); for (const b of solana) b.nativeUsd = b.native * solPrice; @@ -132,6 +134,10 @@ async function computeTotals( if (mints.size > 0) { try { const prices = await getSolanaPrices([...mints]); + const unpriced = [...mints].filter((mint) => !prices.has(mint)); + if (unpriced.length > 0) { + errors.push(`${unpriced.length} token price${unpriced.length === 1 ? '' : 's'} unavailable; USD totals are incomplete.`); + } for (const b of solana) { for (const t of b.tokens) { const p = prices.get(t.mint); @@ -143,6 +149,7 @@ async function computeTotals( } } catch (err) { log.warn('Token pricing failed', err); + errors.push(`Token prices: ${errMessage(err)}`); } } diff --git a/src/services/reconcile.ts b/src/services/reconcile.ts index 8487b98..a2da0f6 100644 --- a/src/services/reconcile.ts +++ b/src/services/reconcile.ts @@ -62,6 +62,14 @@ async function paced(fn: () => Promise): Promise { return out; } +/** Injectable reads and pacing allow history scans to be checked offline. */ +export interface ReconcileServices { + rpc: () => Pick, 'getSignaturesForAddress' | 'getParsedTransactions'>; + paced: typeof paced; +} + +const reconcileServices: ReconcileServices = { rpc, paced }; + /** * Every sale of every mint one wallet made, in SOL that actually arrived. * @@ -73,10 +81,11 @@ async function paced(fn: () => Promise): Promise { * have succeeded returns what was found and says the scan is incomplete, since * partial proceeds still beat none — they can only raise the recorded figure. */ -async function proceedsByMint( +export async function proceedsByMint( address: string, notBefore: number, onProgress?: ProgressFn, + services: ReconcileServices = reconcileServices, ): Promise<{ found: Map; scanned: number; complete: boolean }> { const found = new Map(); const owner = new PublicKey(address); @@ -85,12 +94,13 @@ async function proceedsByMint( let seen = 0; let scanned = 0; let pages = 0; + let incomplete = false; while (seen < SIGNATURE_LIMIT) { let page; try { - page = await paced(() => - rpc().getSignaturesForAddress(owner, { limit: SIGNATURE_PAGE, before }), + page = await services.paced(() => + services.rpc().getSignaturesForAddress(owner, { limit: SIGNATURE_PAGE, before }), ); } catch (err) { // nothing read at all is a failure; a short read is a partial answer @@ -99,15 +109,15 @@ async function proceedsByMint( return { found, scanned, complete: false }; } pages++; - if (page.length === 0) break; + if (page.length === 0) return { found, scanned, complete: !incomplete }; const usable = page.filter((s) => !s.err).map((s) => s.signature); for (let i = 0; i < usable.length; i += PARSE_BATCH) { let txs; try { - txs = await paced(() => - rpc().getParsedTransactions(usable.slice(i, i + PARSE_BATCH), { + txs = await services.paced(() => + services.rpc().getParsedTransactions(usable.slice(i, i + PARSE_BATCH), { maxSupportedTransactionVersion: 0, }), ); @@ -119,8 +129,14 @@ async function proceedsByMint( throw err; } + if (txs.length !== usable.slice(i, i + PARSE_BATCH).length) incomplete = true; + for (const tx of txs) { - if (!tx?.meta || tx.meta.err) continue; + if (!tx?.meta) { + incomplete = true; + continue; + } + if (tx.meta.err) continue; scanned++; const moves = detectTokenMoves( @@ -163,14 +179,16 @@ async function proceedsByMint( // history older than the first position cannot contain one of its sales const oldest = page.at(-1)?.blockTime; if (oldest !== undefined && oldest !== null && oldest * 1000 < notBefore) { - return { found, scanned, complete: true }; + return { found, scanned, complete: !incomplete }; } before = page.at(-1)?.signature; - if (page.length < SIGNATURE_PAGE) break; + if (page.length < SIGNATURE_PAGE) return { found, scanned, complete: !incomplete }; } - return { found, scanned, complete: true }; + // Hitting the request budget does not establish that the remaining history + // contains no sales. Keep the measured proceeds, but report the short scan. + return { found, scanned, complete: false }; } /** @@ -180,7 +198,10 @@ async function proceedsByMint( * older sale can fall outside it — reading that as "this position returned * less than we thought" would replace one wrong number with another. */ -export async function rebuildRealised(onProgress?: ProgressFn): Promise { +export async function rebuildRealised( + onProgress?: ProgressFn, + services: ReconcileServices = reconcileServices, +): Promise { const wallets = allWallets(); const positions = db.positions(); @@ -201,7 +222,7 @@ export async function rebuildRealised(onProgress?: ProgressFn): Promise Promise; +/** Injectable trade services let the execution paths be checked without signing. */ +export interface WatcherTradeServices { + selectWallets: typeof selectWallets; + getMintBalances: typeof getMintBalances; + batchPumpTrade: typeof batchPumpTrade; + measureTokensGained: typeof measureTokensGained; + measureTokensSold: typeof measureTokensSold; +} + +const tradeServices: WatcherTradeServices = { + selectWallets, + getMintBalances, + batchPumpTrade, + measureTokensGained, + measureTokensSold, +}; + const TICK_MS = 20_000; /** Hourly. The store keeps a month of these; more resolution buys nothing. */ @@ -303,7 +320,12 @@ async function rearm(rule: AutoRule, reason: string, notify: Notifier): Promise< ).catch(() => {}); } -async function fire(rule: AutoRule, price: number, notify: Notifier): Promise { +export async function fire( + rule: AutoRule, + price: number, + notify: Notifier, + services: WatcherTradeServices = tradeServices, +): Promise { // Marked before the attempt, never after: a crash between here and the sell // must not leave a rule that fires again on the next tick. A batch that comes // back having landed nothing is a different thing entirely, and `rearm` puts @@ -316,6 +338,11 @@ async function fire(rule: AutoRule, price: number, notify: Notifier): Promise w.address); - const heldBefore = await getMintBalances(addresses, rule.mint).catch(() => undefined); + const heldBefore = await services.getMintBalances(addresses, rule.mint).catch(() => undefined); - const summary = await batchPumpTrade(wallets, { + tradeStarted = true; + const summary = await services.batchPumpTrade(wallets, { action: 'buy', mint: rule.mint, amount: rule.buySol ?? 0, @@ -349,15 +377,20 @@ async function fire(rule: AutoRule, price: number, notify: Notifier): Promise r.ok && r.signature).length; + confirmedFills = fills; // an order that bought nothing has not been filled, and retiring it here // is how a limit buy silently stops existing at the price it was set for if (fills === 0) { + if (summary.results.some((r) => r.confirmationUnknown)) { + await reportUncertainRule(rule, firstFailure(summary) ?? 'confirmation is unavailable', notify); + return; + } await rearm(rule, firstFailure(summary) ?? 'every wallet failed to buy', notify); return; } - const gained = await measureTokensGained(addresses, rule.mint, heldBefore, undefined); + const gained = await services.measureTokensGained(addresses, rule.mint, heldBefore, undefined); db.recordBuy(rule.mint, { solSpent: (rule.buySol ?? 0) * fills, fills, @@ -366,6 +399,7 @@ async function fire(rule: AutoRule, price: number, notify: Notifier): Promise r.confirmationUnknown) + ? ['Some trades may still land. Check the wallets before placing another order.'] + : []), ].join('\n'), - ); + ).catch(() => {}); return; } - const holders = await getMintBalances(wallets.map((w) => w.address), rule.mint).catch(() => new Map()); + const holders = await services.getMintBalances(wallets.map((w) => w.address), rule.mint); if (holders.size === 0) { - await notify(`⚠️ ${label}: ${describe(rule)} triggered, but no wallet holds it any more.`); + await notify(`⚠️ ${label}: ${describe(rule)} triggered, but no wallet holds it any more.`).catch(() => {}); return; } - const summary = await batchPumpTrade(wallets, { + tradeStarted = true; + const summary = await services.batchPumpTrade(wallets, { action: 'sell', mint: rule.mint, amount: rule.sellPercent, @@ -407,9 +445,14 @@ async function fire(rule: AutoRule, price: number, notify: Notifier): Promise r.ok && r.signature).length; + confirmedFills = fills; // nothing landed, so the protection did not run — put it back if (fills === 0) { + if (summary.results.some((r) => r.confirmationUnknown)) { + await reportUncertainRule(rule, firstFailure(summary) ?? 'confirmation is unavailable', notify); + return; + } await rearm(rule, firstFailure(summary) ?? 'every wallet failed to sell', notify); return; } @@ -419,7 +462,7 @@ async function fire(rule: AutoRule, price: number, notify: Notifier): Promise w.address), rule.mint, holders, @@ -450,16 +493,36 @@ async function fire(rule: AutoRule, price: number, notify: Notifier): Promise= 0 ? '+' : ''}${movePct.toFixed(1)}% from entry)` : ''}`, `Sold ${rule.sellPercent}% across ${summary.results.length} wallets`, `✅ ${summary.succeeded} ❌ ${summary.failed}`, + ...(summary.results.some((r) => r.confirmationUnknown) + ? ['Some trades may still land. Check the wallets before placing another order.'] + : []), ...(outcome ? [formatExit(outcome)] : []), ].join('\n'), - ); + ).catch(() => {}); } catch (err) { - // a throw here happens before any transaction is sent — the wallet set, - // the balances and the settings are all read first — so nothing landed - await rearm(rule, errMessage(err), notify); + if (!tradeStarted) { + await rearm(rule, errMessage(err), notify); + } else if (confirmedFills > 0) { + log.warn(`Rule ${rule.id} filled, but follow-up processing failed: ${errMessage(err)}`); + await notify( + `⚠️ ${describe(rule)} traded, but its follow-up failed — ${label}\n\n` + + `${h(errMessage(err))}\n\nIt will not trade again. Check the position and trade history.`, + ).catch(() => {}); + } else { + await reportUncertainRule(rule, errMessage(err), notify); + } } } +async function reportUncertainRule(rule: AutoRule, reason: string, notify: Notifier): Promise { + log.warn(`Rule ${rule.id} may have submitted a trade; automatic retry withheld: ${reason}`); + await notify( + `⚠️ ${describe(rule)} needs a confirmation check — ${h(rule.symbol ?? rule.mint.slice(0, 8))}\n\n` + + `${h(reason)}\n\nA trade may still land. This rule will not retry automatically; ` + + 'check the wallet before placing another order.', + ).catch(() => {}); +} + /** The first distinct reason the wallets gave, for a message worth reading. */ function firstFailure(summary: { results: Array<{ ok: boolean; error?: string }> }): string | undefined { return summary.results.find((r) => !r.ok && r.error)?.error?.slice(0, 160); @@ -478,15 +541,19 @@ export function describe(rule: AutoRule): string { /** * Run any averaging-in rounds that have come due. * - * A round that fails still advances the schedule. Retrying a missed buy at the - * next tick would bunch the purchases together, which is the opposite of what - * averaging in is for. + * A definitely failed round is returned for a retry. An uncertain submission + * keeps its round claimed and pauses the plan until the operator checks it. */ -async function runDueDca(notify: Notifier): Promise { +export async function runDueDca( + notify: Notifier, + services: WatcherTradeServices = tradeServices, +): Promise { for (const plan of db.dueDcaPlans()) { - const wallets = selectWallets(); + const wallets = services.selectWallets(); const settings = db.settings(); - const round = plan.roundsDone + 1; + // The store mutates plan in place, so keep the previous count by value. + const previousRoundsDone = plan.roundsDone; + const round = previousRoundsDone + 1; /* * Counted before the buy, so a crash cannot spend the round twice — and @@ -501,11 +568,15 @@ async function runDueDca(notify: Notifier): Promise { nextRunAt: Date.now() + plan.intervalMinutes * 60_000, }); + let tradeStarted = false; + let confirmedFills = 0; + try { const addresses = wallets.map((w) => w.address); - const heldBefore = await getMintBalances(addresses, plan.mint).catch(() => undefined); + const heldBefore = await services.getMintBalances(addresses, plan.mint).catch(() => undefined); - const summary = await batchPumpTrade(wallets, { + tradeStarted = true; + const summary = await services.batchPumpTrade(wallets, { action: 'buy', mint: plan.mint, amount: plan.buySol, @@ -516,11 +587,21 @@ async function runDueDca(notify: Notifier): Promise { }); const fills = summary.results.filter((r) => r.ok && r.signature).length; + confirmedFills = fills; + const confirmationUnknown = summary.results.some((r) => r.confirmationUnknown); + if (confirmationUnknown) db.updateDcaPlan(plan.id, { enabled: false }); if (fills === 0) { + if (confirmationUnknown) { + await notify( + `⚠️ DCA round ${round}/${plan.roundsTotal} needs a confirmation check\n\n` + + 'A trade may still land. The plan is paused; check the wallet before resuming it.', + ).catch(() => {}); + continue; + } // put the round back and try it on the next tick rather than the next // interval — a congested block should cost seconds, not an hour - db.updateDcaPlan(plan.id, { roundsDone: plan.roundsDone, nextRunAt: Date.now() }); + db.updateDcaPlan(plan.id, { roundsDone: previousRoundsDone, nextRunAt: Date.now() }); log.warn(`DCA round ${round}/${plan.roundsTotal} for ${plan.mint} bought nothing; round returned.`); await notify( `⚠️ DCA round ${round}/${plan.roundsTotal} did not go through\n\n` + @@ -529,7 +610,7 @@ async function runDueDca(notify: Notifier): Promise { continue; } - const gained = await measureTokensGained(addresses, plan.mint, heldBefore, undefined); + const gained = await services.measureTokensGained(addresses, plan.mint, heldBefore, undefined); db.recordBuy(plan.mint, { solSpent: plan.buySol * fills, fills, @@ -554,11 +635,23 @@ async function runDueDca(notify: Notifier): Promise { `🔁 DCA round ${round}/${plan.roundsTotal} — ${h(plan.symbol ?? plan.mint.slice(0, 8))}`, `Bought ${plan.buySol} SOL × ${wallets.length} wallets`, `✅ ${summary.succeeded} ❌ ${summary.failed}`, - done ? '\nPlan complete.' : `\nNext round in ${plan.intervalMinutes} minutes.`, + confirmationUnknown + ? '\nSome trades may still land. The plan is paused; check the wallets before resuming it.' + : done ? '\nPlan complete.' : `\nNext round in ${plan.intervalMinutes} minutes.`, ].join('\n'), - ); + ).catch(() => {}); } catch (err) { - await notify(`❌ DCA round ${round} failed: ${errMessage(err)}`).catch(() => {}); + if (!tradeStarted) { + db.updateDcaPlan(plan.id, { roundsDone: previousRoundsDone, nextRunAt: Date.now() }); + } else if (confirmedFills === 0) { + db.updateDcaPlan(plan.id, { enabled: false }); + } + await notify( + `❌ DCA round ${round} failed: ${h(errMessage(err))}` + + (tradeStarted && confirmedFills === 0 + ? '\n\nA trade may still land. The plan is paused; check the wallet before resuming it.' + : ''), + ).catch(() => {}); } } } diff --git a/src/store/db.ts b/src/store/db.ts index 5cd9367..ea39eb5 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -437,13 +437,46 @@ function migrateSettings(stored: Partial | undefined): Settings { * back over the only copy on the next flush, which turns a bad read into a * permanent loss. * - * The backup is written before each replacement, so it is the last document - * that parsed. Using it costs whatever changed since; ignoring it costs - * everything. + * The backup is written after a completed replacement. It can also recover + * a missing primary file; treating that case as a new install would erase it. */ +function parseDocument(contents: string): Partial { + const parsed: unknown = JSON.parse(contents); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Wallet store must be a JSON object.'); + } + const document = parsed as Partial; + if (document.version !== 1 || !Array.isArray(document.wallets)) { + throw new Error('Wallet store has an unsupported version or missing wallet list.'); + } + for (const wallet of document.wallets) { + if (!wallet || typeof wallet !== 'object' || + !['id', 'kind', 'address', 'secret'].every((key) => + typeof (wallet as unknown as Record)[key] === 'string' && + (wallet as unknown as Record)[key] !== '')) { + throw new Error('Wallet store contains an invalid wallet record.'); + } + } + for (const key of ['tradeLog', 'rules', 'copyTargets', 'dcaPlans', 'valueMarks', 'copyDecisions'] as const) { + if (document[key] !== undefined && !Array.isArray(document[key])) { + throw new Error(`Wallet store field ${key} must be an array.`); + } + } + for (const key of ['settings', 'positions'] as const) { + const value = document[key]; + if (value !== undefined && (!value || typeof value !== 'object' || Array.isArray(value))) { + throw new Error(`Wallet store field ${key} must be an object.`); + } + } + if (document.mnemonic !== undefined && typeof document.mnemonic !== 'string') { + throw new Error('Wallet store mnemonic must be encrypted text.'); + } + return document; +} + function readDocument(): Partial { try { - return JSON.parse(fs.readFileSync(dbPath(), 'utf8')) as Partial; + return parseDocument(fs.readFileSync(dbPath(), 'utf8')); } catch (err) { const backup = `${dbPath()}.bak`; if (!fs.existsSync(backup)) throw err; @@ -452,7 +485,7 @@ function readDocument(): Partial { // for, and reporting the original failure is more use than reporting this let recovered: Partial; try { - recovered = JSON.parse(fs.readFileSync(backup, 'utf8')) as Partial; + recovered = parseDocument(fs.readFileSync(backup, 'utf8')); } catch { throw err; } @@ -475,7 +508,7 @@ function readDocument(): Partial { function load(): DbShape { if (cache) return cache; - if (!fs.existsSync(dbPath())) { + if (!fs.existsSync(dbPath()) && !fs.existsSync(`${dbPath()}.bak`)) { cache = { version: 1, wallets: [], settings: defaultSettings(), tradeLog: [], positions: {}, rules: [], copyTargets: [], dcaPlans: [], valueMarks: [], copyDecisions: [] }; return cache; } @@ -823,7 +856,7 @@ export const db = { * wallets immediately rather than writing them back out on the next flush. */ wipe(): void { - fs.rmSync(dbPath(), { force: true }); + for (const suffix of ['', '.bak', '.corrupt']) fs.rmSync(`${dbPath()}${suffix}`, { force: true }); cache = { version: 1, wallets: [], settings: defaultSettings(), tradeLog: [], positions: {}, rules: [], copyTargets: [], dcaPlans: [], valueMarks: [], copyDecisions: [] }; }, diff --git a/src/store/vault.ts b/src/store/vault.ts index 347f6fc..46c0ba0 100644 --- a/src/store/vault.ts +++ b/src/store/vault.ts @@ -98,14 +98,47 @@ function open(key: Buffer, blob: string): Buffer { // ── vault file lifecycle ────────────────────────────────────────────────────── export function vaultExists(): boolean { - return fs.existsSync(vaultPath()); + return fs.existsSync(vaultPath()) || fs.existsSync(`${vaultPath()}.bak`); +} + +function parseVaultFile(contents: string): VaultFile { + const parsed: unknown = JSON.parse(contents); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid vault metadata.'); + const file = parsed as VaultFile; + if (file.version !== 1) throw new Error(`Unsupported vault version ${file.version}.`); + if (file.mode !== undefined && file.mode !== 'passphrase' && file.mode !== 'keyfile') { + throw new Error('Invalid vault mode.'); + } + if (typeof file.verifier !== 'string' || Buffer.from(file.verifier, 'base64').length < 29 || + !Number.isFinite(file.createdAt)) throw new Error('Invalid vault verifier or creation time.'); + if ((file.mode ?? 'passphrase') === 'passphrase') { + const kdf = file.kdf; + if (!kdf || kdf.algo !== 'scrypt' || kdf.keyLen !== KDF.keyLen || + ![kdf.N, kdf.r, kdf.p].every((value) => Number.isSafeInteger(value) && value > 0) || + typeof kdf.salt !== 'string' || Buffer.from(kdf.salt, 'base64').length < 16) { + throw new Error('Invalid vault key derivation metadata.'); + } + } + return file; } function readVaultFile(): VaultFile { - const raw = fs.readFileSync(vaultPath(), 'utf8'); - const parsed = JSON.parse(raw) as VaultFile; - if (parsed.version !== 1) throw new Error(`Unsupported vault version ${parsed.version}.`); - return parsed; + try { + return parseVaultFile(fs.readFileSync(vaultPath(), 'utf8')); + } catch (err) { + let recovered: VaultFile; + try { + recovered = parseVaultFile(fs.readFileSync(`${vaultPath()}.bak`, 'utf8')); + } catch { + throw err; + } + if (fs.existsSync(vaultPath())) { + try { fs.copyFileSync(vaultPath(), `${vaultPath()}.corrupt`); } catch { /* best effort */ } + } + writeAtomic(vaultPath(), JSON.stringify(recovered, null, 2)); + log.warn('Vault metadata recovered from its backup.'); + return recovered; + } } /** Which mode the stored vault uses. Vaults predating keyfile mode are passphrase. */ @@ -205,37 +238,29 @@ export function unlockFromKeyfile(): boolean { } /** - * Drop the passphrase: re-seal every secret under a fresh random key and write - * that key to disk. Requires an unlocked vault, so only somebody who already - * knows the passphrase can trade it away. + * Drop the passphrase by saving the verified master key to disk. Keeping the + * same encryption key means every wallet and its backup remains decryptable + * if the process stops between the key-file and metadata writes. + * Requires an unlocked vault, so the old passphrase must be known first. */ export function removePassphrase( - reseal: (decrypt: (blob: string) => string, encrypt: (plain: string) => string) => void, + // Retained for callers of the earlier rotation API. No secrets need resealing. + _reseal: (decrypt: (blob: string) => string, encrypt: (plain: string) => string) => void, ): void { - const oldKey = requireKey(); + const key = requireKey(); if (vaultMode() === 'keyfile') throw new Error('This vault already has no passphrase.'); - const newKey = crypto.randomBytes(KDF.keyLen); - - // secrets are re-sealed before the vault file changes, so a crash in the - // middle leaves a vault that still opens with the old passphrase - reseal( - (blob) => open(oldKey, blob).toString('utf8'), - (plain) => seal(newKey, plain), - ); - const file: VaultFile = { version: 1, mode: 'keyfile', - verifier: seal(newKey, VERIFIER_PLAINTEXT), + verifier: seal(key, VERIFIER_PLAINTEXT), createdAt: readVaultFile().createdAt, }; - writeAtomic(keyfilePath(), newKey.toString('base64')); + // A failure here leaves the original vault metadata and ciphertext intact. + // Once the key file is durable, switching the mode is one atomic write. + writeAtomic(keyfilePath(), key.toString('base64')); writeAtomic(vaultPath(), JSON.stringify(file, null, 2)); - - oldKey.fill(0); - masterKey = newKey; if (autolockTimer) { clearTimeout(autolockTimer); autolockTimer = null; @@ -307,9 +332,8 @@ export function isUnlocked(): boolean { /** * Unlock an old passphrase vault and immediately convert it. * - * The only remaining reason to type a passphrase: a vault created before - * passphrases were dropped still has its secrets sealed under one, and moving - * them needs the key that opens them. This is asked once and then never again. + * The only remaining reason to type a passphrase: an older vault still needs + * its master key derived once before that key can be saved for future boots. */ export async function unlockAndConvert( passphrase: string, @@ -376,16 +400,21 @@ export function writeAtomic(file: string, contents: string): void { * carrying for the sake of one syscall. */ const tmp = `${file}.${process.pid}.tmp`; - const fd = fs.openSync(tmp, 'w', 0o600); try { - fs.writeFileSync(fd, contents); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); + const fd = fs.openSync(tmp, 'w', 0o600); + try { + fs.writeFileSync(fd, contents); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmp, file); + } catch (err) { + // A failed replacement must not leave another copy of the master key. + try { fs.rmSync(tmp, { force: true }); } catch { /* retain the original error */ } + throw err; } - fs.renameSync(tmp, file); - // and the rename itself, so the directory entry is as durable as the file try { const dir = fs.openSync(path.dirname(file), 'r'); @@ -426,9 +455,11 @@ export function writeAtomic(file: string, contents: string): void { */ export function destroyVault(): void { lockVault(); - fs.rmSync(vaultPath(), { force: true }); - // the key file goes too, or the next vault inherits a stale one - fs.rmSync(keyfilePath(), { force: true }); + // Remove recovery copies too, so a deliberate reset cannot restore the old + // vault or leave its plaintext master key behind. + for (const file of [vaultPath(), keyfilePath()]) { + for (const suffix of ['', '.bak', '.corrupt']) fs.rmSync(`${file}${suffix}`, { force: true }); + } log.warn('Vault destroyed. Every stored key is now unrecoverable.'); } @@ -441,6 +472,9 @@ export function destroyVault(): void { */ export function openAtBoot(hasSecrets: boolean): 'opened' | 'created' | 'needs-passphrase' { if (!vaultExists()) { + if (hasSecrets) { + throw new Error('Vault metadata is missing while encrypted wallet secrets still exist. Restore vault.json from a backup before starting; no new key was created.'); + } initVaultWithKeyfile(); return 'created'; } diff --git a/src/store/wallets.ts b/src/store/wallets.ts index f781ce0..e32e027 100644 --- a/src/store/wallets.ts +++ b/src/store/wallets.ts @@ -26,8 +26,7 @@ export function walletById(id: string): WalletRecord | undefined { } export function walletByAddress(address: string): WalletRecord | undefined { - const needle = address.toLowerCase(); - return db.wallets().find((w) => w.address.toLowerCase() === needle); + return db.wallets().find((w) => w.address === address); } export function mainWallet(): WalletRecord | undefined { @@ -122,7 +121,7 @@ function nextLabel(): string { function insert(rec: Omit): WalletRecord { const wallets = db.wallets(); - const existing = wallets.find((w) => w.address.toLowerCase() === rec.address.toLowerCase()); + const existing = wallets.find((w) => w.address === rec.address); if (existing) throw new Error(`Wallet ${rec.address} is already in the list as "${existing.label}".`); const full: WalletRecord = { ...rec, id: crypto.randomUUID(), createdAt: Date.now() }; @@ -322,7 +321,11 @@ export function resealAll( encrypt: (plain: string) => string, ): void { const raw = db.raw(); - for (const w of raw.wallets) w.secret = encrypt(decrypt(w.secret)); - if (raw.mnemonic) raw.mnemonic = encrypt(decrypt(raw.mnemonic)); + // Prepare everything before changing the live document. A corrupt later + // secret must not leave earlier records sealed under a different key. + const secrets = raw.wallets.map((w) => encrypt(decrypt(w.secret))); + const mnemonic = raw.mnemonic ? encrypt(decrypt(raw.mnemonic)) : undefined; + for (const [index, w] of raw.wallets.entries()) w.secret = secrets[index]!; + raw.mnemonic = mnemonic; flush(); } diff --git a/src/trade/engine.ts b/src/trade/engine.ts index 98b6cd2..dc0564b 100644 --- a/src/trade/engine.ts +++ b/src/trade/engine.ts @@ -1,7 +1,7 @@ import bs58 from 'bs58'; import type { VersionedTransaction, Keypair } from '@solana/web3.js'; import { config, type ExecutionMode } from '../config.js'; -import { chunk, pMap, errMessage, retry } from '../util.js'; +import { chunk, pMap, errMessage, sleep } from '../util.js'; import { log } from '../logger.js'; import { solanaKeypair } from '../store/wallets.js'; import { db } from '../store/db.js'; @@ -12,7 +12,6 @@ import { getSplBalances, getTokenBalance, getMintBalances, - signatureLanded, recentPriorityFeeMicroLamports, priorityFeeSolFromMicroLamports, WSOL_MINT, @@ -22,6 +21,7 @@ import { sendBundle, waitForBundle, recentJitoTipSol, JITO_MIN_TIP_SOL } from '. import { detectPool, PUMP_PROGRAM_ID } from './curve.js'; import { swapToSol, swapFromSol } from './jupiter.js'; import { fundingBalances, partitionByBalance, requiredForBuy, exitReserveLamports } from './fund.js'; +import { TransactionRejectedError, TransactionSubmissionUnknownError } from './errors.js'; /** * The multiplier an exit is allowed to bid, mirrored from the watcher. @@ -36,6 +36,15 @@ import type { WalletRecord, TradeRequest, ExecutionResult, BatchSummary } from ' export type ProgressFn = (done: number, total: number, note?: string) => void | Promise; +async function reportProgress(onProgress: ProgressFn | undefined, done: number, total: number, note?: string): Promise { + try { + await onProgress?.(done, total, note); + } catch (err) { + // A Telegram update failing cannot change whether a transaction succeeded. + log.warn(`Could not update trade progress: ${errMessage(err)}`); + } +} + function summarise(results: ExecutionResult[], startedAt: number): BatchSummary { return { results, @@ -121,7 +130,17 @@ function fail(w: WalletRecord, err: unknown): ExecutionResult { // watcher has no screen — copy trading reported "❌ 1" and left nothing // anywhere to say why. Every failure gets written down. log.warn(`${w.label} (${w.address.slice(0, 8)}…) failed: ${errMessage(err)}`); - return { walletId: w.id, label: w.label, address: w.address, ok: false, error: errMessage(err) }; + return { + walletId: w.id, + label: w.label, + address: w.address, + ok: false, + error: errMessage(err), + ...((err instanceof TransactionSubmissionUnknownError || err instanceof TransactionRejectedError) + ? { signature: err.signature } + : {}), + ...(err instanceof TransactionSubmissionUnknownError ? { confirmationUnknown: true } : {}), + }; } // ── pump.fun batch trading ──────────────────────────────────────────────────── @@ -248,7 +267,7 @@ export async function batchPumpTrade( active, balances, requiredForBuy(req.amount, req.priorityFeeSol, { - jitoTipSol: db.settings().executionMode === 'bundle' ? db.settings().jitoTipSol : 0, + jitoTipSol: mode === 'bundle' ? settings.jitoTipSol : 0, // anything off the curve is an SPL pool, so the buy wraps SOL first wrapsSol: detectedPool !== 'pump', }), @@ -468,34 +487,21 @@ async function tradeOneWallet( } try { - let lastSignature: string | undefined; - - const signature = await retry( - async () => { - if (lastSignature) { - // a retry after a timeout must not spend again if the first attempt - // landed — nor if we simply cannot tell whether it did - const state = await signatureLanded(lastSignature); - if (state === 'landed') return lastSignature; - if (state === 'unknown') { - throw new Error( - `Sent ${lastSignature.slice(0, 12)}… but could not confirm it. Not retried, ` + - 'to avoid trading twice — check the wallet before trying again.', - ); - } - } - - // rebuilt each attempt so the blockhash is fresh - const tx = lastSignature === undefined ? built : await buildTrade(args); - const signed = signTx(tx, kp); - lastSignature = bs58Signature(signed); - - return sendAndConfirm(signed, { skipPreflight: true }); - }, - { attempts: 2, baseDelayMs: 600 }, - ); - - return { walletId: w.id, label: w.label, address: w.address, ok: true, signature }; + for (let attempt = 0; attempt < 2; attempt++) { + const tx = attempt === 0 ? built : await buildTrade(args); + const signed = signTx(tx, kp); + try { + const signature = await sendAndConfirm(signed, { skipPreflight: true }); + return { walletId: w.id, label: w.label, address: w.address, ok: true, signature }; + } catch (sendErr) { + // Only an explicit chain rejection proves this attempt cannot land. + // Missing status, transport errors and timeouts all keep the original + // signature and stop, rather than rebuilding a second spend. + if (!(sendErr instanceof TransactionRejectedError) || attempt === 1) return fail(w, sendErr); + await sleep(600 + Math.random() * 600); + } + } + throw new Error('Trade attempts exhausted.'); } catch (sendErr) { return fail(w, sendErr); } @@ -527,6 +533,9 @@ async function tradeViaJupiter( const { signature } = await swapToSol(kp, req.mint, rawAmount, slippageBps, req.priorityFeeSol); return { walletId: w.id, label: w.label, address: w.address, ok: true, signature, detail: 'via Jupiter' }; } catch (jupErr) { + if (jupErr instanceof TransactionSubmissionUnknownError) { + return { ...fail(w, jupErr), detail: 'via Jupiter · confirmation unknown' }; + } // the pump.fun error is usually the more informative one; keep both return { ...fail(w, pumpErr), @@ -551,7 +560,7 @@ async function parallelTrades( return fail(w, err); } finally { done++; - await onProgress?.(done, wallets.length); + await reportProgress(onProgress, done, wallets.length); } }); @@ -571,6 +580,7 @@ async function bundleTrades( let done = 0; for (const [gi, group] of groups.entries()) { + let signed: VersionedTransaction[] | undefined; try { // PumpPortal takes the first transaction's priority fee as the Jito tip // for the whole bundle and ignores the rest, so pay it once up front. @@ -599,13 +609,14 @@ async function bundleTrades( throw new Error(`PumpPortal returned ${unsigned.length} transactions for ${group.length} wallets.`); } - const signed = unsigned.map((tx, i) => signTx(tx, solanaKeypair(group[i]!))); + signed = unsigned.map((tx, i) => signTx(tx, solanaKeypair(group[i]!))); const bundleId = await sendBundle(signed); - await onProgress?.(done, wallets.length, `bundle ${gi + 1}/${groups.length} sent`); + await reportProgress(onProgress, done, wallets.length, `bundle ${gi + 1}/${groups.length} sent`); const state = await waitForBundle(bundleId); const ok = state === 'Landed'; + const confirmationUnknown = !ok && state !== 'Failed'; for (const [i, w] of group.entries()) { results.push({ @@ -613,16 +624,22 @@ async function bundleTrades( label: w.label, address: w.address, ok, - signature: ok ? bs58Signature(signed[i]) : undefined, - error: ok ? undefined : `Bundle ${state.toLowerCase()}`, + signature: bs58Signature(signed[i]), + ...(confirmationUnknown ? { confirmationUnknown: true } : {}), + error: ok ? undefined : `Bundle ${state.toLowerCase()}${confirmationUnknown ? ' — check the wallet before retrying' : ''}`, detail: `bundle ${bundleId.slice(0, 8)}…`, }); } } catch (err) { - for (const w of group) results.push(fail(w, err)); + for (const [i, w] of group.entries()) { + results.push({ + ...fail(w, err), + ...(err instanceof TransactionSubmissionUnknownError ? { signature: bs58Signature(signed?.[i]) } : {}), + }); + } } finally { done += group.length; - await onProgress?.(done, wallets.length); + await reportProgress(onProgress, done, wallets.length); } } @@ -744,6 +761,7 @@ export async function batchSweepToken( settings.priorityFeeSol, holding.programId, true, // close the emptied account and reclaim its rent + holding.tokenAccount, ); return { diff --git a/src/trade/errors.ts b/src/trade/errors.ts new file mode 100644 index 0000000..317b044 --- /dev/null +++ b/src/trade/errors.ts @@ -0,0 +1,21 @@ +import { errMessage } from '../util.js'; + +/** A definite rejection: this attempt cannot later execute successfully. */ +export class TransactionRejectedError extends Error { + constructor(message: string, readonly signature?: string) { + super(message); + this.name = 'TransactionRejectedError'; + } +} + +/** Submission may have succeeded. Never rebuild or retry the spend blindly. */ +export class TransactionSubmissionUnknownError extends Error { + constructor(readonly signature: string | undefined, cause: unknown) { + super( + `Could not confirm ${signature ? `transaction ${signature}` : 'transaction submission'}: ` + + `${errMessage(cause)} Check the wallet before retrying.`, + { cause }, + ); + this.name = 'TransactionSubmissionUnknownError'; + } +} diff --git a/src/trade/fund.ts b/src/trade/fund.ts index 20c0171..2d2bd70 100644 --- a/src/trade/fund.ts +++ b/src/trade/fund.ts @@ -9,6 +9,7 @@ import { import { solanaKeypair } from '../store/wallets.js'; import { chunk, errMessage } from '../util.js'; import { log } from '../logger.js'; +import { TransactionRejectedError, TransactionSubmissionUnknownError } from './errors.js'; import type { WalletRecord, ExecutionResult, BatchSummary } from '../types.js'; import type { ProgressFn } from './engine.js'; @@ -189,6 +190,10 @@ export async function executeFunding( address: t.address, ok: false, error: errMessage(err), + ...((err instanceof TransactionSubmissionUnknownError || err instanceof TransactionRejectedError) + ? { signature: err.signature } + : {}), + ...(err instanceof TransactionSubmissionUnknownError ? { confirmationUnknown: true } : {}), }); } } finally { diff --git a/src/trade/jito.ts b/src/trade/jito.ts index 1ce9cd7..00916d4 100644 --- a/src/trade/jito.ts +++ b/src/trade/jito.ts @@ -2,6 +2,7 @@ import { VersionedTransaction } from '@solana/web3.js'; import bs58 from 'bs58'; import { endpoints } from '../config.js'; import { fetchJson, sleep } from '../util.js'; +import { TransactionRejectedError, TransactionSubmissionUnknownError } from './errors.js'; /** * Jito bundle submission. A bundle is an ordered list of up to 5 transactions @@ -23,15 +24,23 @@ export async function sendBundle(transactions: VersionedTransaction[]): Promise< const encoded = transactions.map((tx) => bs58.encode(tx.serialize())); - const res = await fetchJson>(endpoints.jitoBundles, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'sendBundle', params: [encoded] }), - timeoutMs: 20_000, - }); + const firstSignature = bs58.encode(transactions[0]!.signatures[0]!); + let res: JitoRpcResponse; + try { + res = await fetchJson>(endpoints.jitoBundles, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'sendBundle', params: [encoded] }), + timeoutMs: 20_000, + }); + } catch (err) { + throw new TransactionSubmissionUnknownError(firstSignature, err); + } - if (res.error) throw new Error(`Jito rejected the bundle: ${res.error.message}`); - if (!res.result) throw new Error('Jito accepted the request but returned no bundle id.'); + if (res.error) throw new TransactionRejectedError(`Jito rejected the bundle: ${res.error.message}`); + if (!res.result) { + throw new TransactionSubmissionUnknownError(firstSignature, 'Jito returned no bundle id.'); + } return res.result; } @@ -49,9 +58,22 @@ export async function getBundleStatus(bundleId: string): Promise { }, ); + if (res.error) return 'Unknown'; const entry = res.result?.value?.[0]; if (!entry) return 'Pending'; - if (entry.err) return 'Failed'; + // Jito serializes a successful Rust Result as { Ok: null }, not just null. + // Treating that object as an error hides real fills and can rearm an order + // which has already traded. An explicit Err always takes precedence. + const err = entry.err; + const success = + err === null || + err === undefined || + (typeof err === 'object' && + !Array.isArray(err) && + Object.keys(err).length === 1 && + 'Ok' in err && + err.Ok === null); + if (!success) return 'Failed'; if (entry.confirmation_status === 'confirmed' || entry.confirmation_status === 'finalized') return 'Landed'; return 'Pending'; } catch { diff --git a/src/types.ts b/src/types.ts index 1818f4e..c1d6afe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,8 @@ export interface ExecutionResult { address: string; ok: boolean; signature?: string; + /** A submitted transaction may still land. Automatic retries must stop. */ + confirmationUnknown?: boolean; txHash?: string; error?: string; /** Populated for trades: how much was actually spent/received. */ diff --git a/tsconfig.json b/tsconfig.json index 40c6dae..a478959 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "noEmit": true, "verbatimModuleSyntax": false }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "scripts/*-regressions.ts"] } From 0fc00575c7b5a371401169730f572677c32eff91 Mon Sep 17 00:00:00 2001 From: Wraith <68072890+wraithioner@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:12:21 +0200 Subject: [PATCH 2/4] Harden builder authorization, copy receipts, execution lifecycle, and accounting --- .env.example | 11 +- .github/workflows/check.yml | 5 +- DEEP_REVIEW.md | 124 ++++++ README.md | 48 ++- REVIEW.md | 35 +- package-lock.json | 62 +-- package.json | 9 +- scripts/accounting-regressions.ts | 246 ++++++++++++ scripts/client-regressions.ts | 160 ++++++++ scripts/concurrency-regressions.ts | 281 +++++++++++++ scripts/copyevents-regressions.ts | 330 +++++++++++++++ scripts/deep-transaction-regressions.ts | 232 +++++++++++ .../fixtures/external-builder-unsigned.json | 112 ++++++ scripts/netcheck.ts | 13 +- scripts/portfolio-regressions.ts | 28 +- scripts/reconcile-deep-regressions.ts | 377 ++++++++++++++++++ scripts/reconcile-regressions.ts | 25 +- scripts/safety-regressions.ts | 133 ++++++ scripts/smoke.ts | 35 +- scripts/transaction-regressions.ts | 52 ++- src/bot/handlers/core.ts | 45 ++- src/bot/handlers/trade.ts | 125 ++++-- src/bot/handlers/wallets.ts | 8 +- src/bot/session.ts | 33 +- src/bot/ui.ts | 19 +- src/chains/solana.ts | 111 ++++-- src/config.ts | 85 +++- src/services/copytrade.ts | 295 +++++++++++--- src/services/execution.ts | 95 +++++ src/services/jupdata.ts | 12 +- src/services/jupiter-client.ts | 127 ++++++ src/services/locks.ts | 69 ++-- src/services/mintauth.ts | 24 +- src/services/pnl.ts | 18 +- src/services/portfolio.ts | 17 +- src/services/prices.ts | 5 +- src/services/reconcile.ts | 310 +++++++++++--- src/services/rugcheck.ts | 34 +- src/services/safety.ts | 104 +++-- src/services/tokeninfo.ts | 180 +++++---- src/services/watcher.ts | 125 ++++-- src/store/db.ts | 81 +++- src/trade/engine.ts | 123 ++++-- src/trade/fund.ts | 10 + src/trade/jito.ts | 4 + src/trade/jupiter.ts | 89 ++++- src/trade/pumpportal.ts | 57 ++- src/trade/validation.ts | 248 ++++++++++++ 48 files changed, 4072 insertions(+), 699 deletions(-) create mode 100644 DEEP_REVIEW.md create mode 100644 scripts/accounting-regressions.ts create mode 100644 scripts/client-regressions.ts create mode 100644 scripts/concurrency-regressions.ts create mode 100644 scripts/copyevents-regressions.ts create mode 100644 scripts/deep-transaction-regressions.ts create mode 100644 scripts/fixtures/external-builder-unsigned.json create mode 100644 scripts/reconcile-deep-regressions.ts create mode 100644 scripts/safety-regressions.ts create mode 100644 src/services/execution.ts create mode 100644 src/services/jupiter-client.ts create mode 100644 src/trade/validation.ts diff --git a/.env.example b/.env.example index 2f87f14..014fcf1 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,15 @@ SOLANA_RPC_URL=https://api.mainnet-beta.solana.com # low-latency "sender" endpoint). Falls back to SOLANA_RPC_URL. # SOLANA_SEND_RPC_URL= +# Jupiter uses the current API gateway for swaps, prices and token metadata. +# Optional API key from Jupiter Developer Platform, sent only to this HTTPS origin. +JUPITER_API_BASE_URL=https://api.jup.ag +# JUPITER_API_KEY= +# Requests across all Jupiter APIs share a queue. Defaults: 2000 ms without a +# key (0.5 RPS), 1000 ms with a key (free plan). Set your plan's interval here. +# The request deadline includes queue wait. Use 0 only in offline test fixtures. +# JUPITER_REQUEST_INTERVAL_MS= + # 4. Vault # There is no passphrase. Keys are encrypted at rest under a random key kept # in data/vault.key, so the bot opens itself after a restart — and anyone who @@ -30,7 +39,7 @@ DEFAULT_PRIORITY_FEE_SOL=0.00005 # "bundle" = atomic Jito bundles, 5 wallets per bundle, best for same-block entries. # "parallel" = fire each wallet's tx independently. More resilient, less atomic. DEFAULT_EXECUTION_MODE=parallel -# Max wallets hitting the RPC at the same time in parallel mode. +# Integer 1-1000: max wallets hitting the RPC at once in parallel mode. EXECUTION_CONCURRENCY=5 # Jito tip in SOL, used only in bundle mode. JITO_TIP_SOL=0.0001 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9ffac71..1cef2f4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,11 +11,14 @@ permissions: jobs: offline-checks: runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: npm - run: npm ci - run: npm run check diff --git a/DEEP_REVIEW.md b/DEEP_REVIEW.md new file mode 100644 index 0000000..03f5e3d --- /dev/null +++ b/DEEP_REVIEW.md @@ -0,0 +1,124 @@ +# Deeper review — 2026-10-02 + +Repository: `wraithioner/solfleet`. Baseline: `817fd8f`; this extends the +first reliability pass in PR #3. Findings were reproduced with injected RPC, +transaction-builder, persistence and lifecycle events. No funds were moved. + +## Verified failures and corrections + +| Area | Reproduced failure | Implemented result | +| --- | --- | --- | +| Builder authorization | An unrelated 10 SOL transfer, foreign payer, appended transfer or excessive compute fee could be signed | Require the wallet as sole signer and payer, recheck before signing, clear supplied signatures, constrain compute/tip budgets and direct SOL/token setup, refuse unknown top-level programs | +| Jupiter quotes | Wrong mint, input, mode or slippage could reach the builder | Bind ExactIn quotes to the exact request; validate canonical positive u64 amounts, output threshold and unexpected platform fees | +| Copy receipts | RPC null consumed an event; socket events replayed after restart; global dedup suppressed a second target | Persist target-scoped receipts only after readable parsing, retry unreadable RPC results, serialize per-target handling and checkpoint resolved receipts | +| Copy history | More than ten recent signatures silently lost older events | Paginate up to 500 signatures; an unprovable history gap pauses the target and preserves its checkpoint | +| Copy lifecycle | Disabled targets could spend after screening; flood protection re-enabled itself | Recheck the target intent through submission; flood protection persistently disables the target and clears queued events | +| Concurrent execution | Two operations checked and spent the same balance; one operation contaminated another's measurements | Hold one FIFO operation through balance checks, submission, measurements and bookkeeping; wallets inside one batch remain concurrent | +| Reset and deletion | A key obtained before reset could sign and submit afterwards | Invalidate queued operations immediately, check authorization just before dispatch, drain active bookkeeping before erasing keys, clear all owner sessions | +| Confirmation context | A confirmation could execute with different wallets or financial settings | Require the same account generation, settings and wallet selection at confirmation; recheck funding deficits under the operation gate | +| Automation cancellation | Removed rules or DCA plans still fired from old snapshots | Verify live instructions before claiming and submitting; disabled or changed automation cancels builds before dispatch | +| Open cost basis | Buy 100 for 1 SOL, sell 90, buy 10 for 1 SOL produced an entry of 2/110 rather than 1.1/20 | Retire basis proportionally on measured sells; unknown quantities and legacy sales invalidate entry instead of guessing | +| Quantity measurement | Nine-decimal tokens were measured as six decimals; a group with no holdings reset the account's basis | Read actual mint decimals and all account wallets; measure confirmed fills only, invalidate mixed uncertain quantities | +| Token reads | Token-2022 outages, null UI amounts and non-associated accounts became zero or disappeared | Read both token programs completely, use validated raw units, aggregate every account and reject confidential/unreadable balances | +| Sweeps | Only one account per mint moved | Transfer all matching accounts, including non-ATAs; harvest withheld Token-2022 fees before closing | +| Token safety | Live pause authorities and malformed mint/numeric facts could pass | Validate owner, initialized mint and authority options; refuse pausable/unreviewed extensions and unread chain facts | +| Concentration | Lower indexed numbers overwrote higher chain facts; account fragmentation understated wallet concentration | Aggregate by owner, retain the strongest observation, include all unsampled supply in a conservative concentration bound | +| Vesting | End dates and unrelated vault balances waived concentration limits | Apply no discount; label outstanding stream balances potentially claimable and remove the misleading discount control | +| History repair | Token transfer plus wallet funding looked like a sale; unrelated mint units split one SOL delta | Require a supported isolated swap, attributable token debit and SOL/WSOL return; unfamiliar/composed history stays incomplete | +| History boundaries | The last scanned page included transactions older than the requested boundary | Apply the cutoff to each transaction, reject unknown timestamps and repeated page receipts; cancel stale repair writes after reset | +| Position cards | Group or unreadable values were compared against the full account's costs | Withhold profit/cost comparisons for filtered, incomplete or unpriced views; unread holdings do not become an empty position set | +| Gateway and configuration | Retiring Jupiter URLs, unshared request limits and malformed environment values were accepted | Use one authenticated, deadline-aware `api.jup.ag` client; strictly validate startup financial and execution settings | +| Dependencies | Jayson brought vulnerable uuid and stream-json paths | Scope an exact Jayson 5.0.0 override to web3.js, with HTTP/RPC compatibility checks; audit falls from 9 findings to 3 inherited high findings | + +## Evidence and validation + +`npm run check` runs strict typechecking, 279 smoke checks and all offline +regression suites: 27 transaction, 15 automation, 11 portfolio, 9 history, +51 external-builder, 14 copy-event, 12 concurrency, 12 accounting, 31 deep +history and 10 client/configuration groups, plus wallet and safety suites. +Regression files are under `scripts/`; five actual unsigned +builder responses are recorded in `scripts/fixtures/external-builder-unsigned.json`. +The captures contain public requests and zero signatures, with no private keys. +They exercise current PumpPortal buy/sell, Jupiter SOL-to-USDC and a two-wallet +Jito bundle. Live probes were read-only and established envelope compatibility, +not successful fills or complete swap intent. + +The integrated live `npm run netcheck` returned **24 passed, 2 failed**. The +graduated-token concentration probe had no holder figure after public-RPC +rate limits and indexed timeouts; the BONK-to-SOL builder request received +Jupiter HTTP 429. Other quotes, prices, unsigned PumpPortal builds and all eight +sampled live-curve Jupiter routes answered. These results do not establish +uninterrupted provider availability. + +The scoped Jayson override also passed isolated single/batch web3 RPC tests, +CJS/browser imports, generated request IDs, notifications and error propagation. +The project checks passed on actual Node 20.18 with its TSX launcher; Node 22.12 +native web3 imports and mocked RPC also passed. CI checks Node 22 and 24. Ordinary +installation scripts remain enabled. Missing native bindings in this workspace +are not a deployment mitigation. + +`npm audit --omit=dev` now reports **3 high, 0 moderate** findings: one unpatched +`bigint-buffer` advisory plus its SPL parent findings. Reviewed SPL conversions +use fixed-width buffers, but this is an applicability observation, not proof of +safety. No unreviewed fork or forced SPL downgrade was substituted. + +## Behavioral tradeoffs + +- The holder bound assumes every unsampled token belongs to the ten largest + wallets. This may refuse tokens whose real distribution is acceptable. It + avoids claiming a twenty-account sample proves complete wallet concentration. + Curve/pool exclusion still depends on recognized on-chain ownership evidence. +- Exhaustive mint balances require a bounded read per wallet rather than an ATA + batch. Correctness improves, with more RPC work and latency. +- A copy receipt is persisted before attempting its trade. A crash after that + claim can miss a copy; it will not automatically repeat spending. Receipts are + bounded to 600 per target, and gaps beyond the polling budget pause copying. +- Keyless Jupiter requests share a 2000 ms interval. Deadlines include queue wait; + expired queued requests do not consume slots. Optional indexed safety facts + may remain absent, while essential chain facts refuse copying when unreadable. +- Unsupported builders, lookup-table program/debit accounts and composed history + are refused or reported incomplete. This is deliberately conservative. + +## Remaining work, in priority order + +1. **Durable per-wallet submission journal and reservations.** Write signed + identities before broadcasting, recover/reconcile them after restart and + block further spending on wallets with unresolved submissions. The current + gate stops in-process overlap; an uncertain transaction can still land after + the gate releases. Accounting for those fills is not automatically repaired. +2. **Complete swap-intent decoding.** Match mint, input cap, minimum output, + recipient, accounts and fee rules to the signed venue instructions. Current + checks protect the envelope and direct instructions, but do not prove all + CPI behavior. PumpPortal currently returns the opaque program + `FAdo9NCw1ssek6Z6yeWzWjhLVsr8uiCwcWNUnKgzTnHe`; no published IDL/source was + found. Its allowlisted presence preserves compatibility and retains builder + trust. Simulation alone would not establish intent. +3. **Non-ATA sale consolidation.** Sweeps handle all accounts, but a Jupiter sell + can still expect an input ATA even when holdings exist elsewhere. Plan bounded + consolidation before quoting; failures currently remain failures, not fills. +4. **Execution and accounting beyond one process.** The FIFO gate is process + local. External wallet activity can contaminate balance deltas; multiple bot + instances are not coordinated. Per-signature accounting and a transactional + store would be stronger for that workload. +5. **Index coverage and repair coverage.** Optional developer-history, trader, + age and insider index fields can be absent. History repair supports bounded + known swap layouts and must not certify unfamiliar wrappers or composed swaps. + Repairs only raise proceeds; older overstatements need a separately reviewed + correction workflow. + +## Primary references + +- [Solana transaction structure](https://solana.com/docs/core/transactions/transaction-structure) +- [Solana compute fees](https://solana.com/docs/core/fees/fee-structure) +- [Solana parsed transaction structures](https://solana.com/docs/rpc/json-structures) +- [Solana signature pagination](https://solana.com/docs/rpc/http/getsignaturesforaddress) +- [Solana Token-2022 pausable mint](https://solana.com/docs/tokens/extensions/pausable) +- [PumpPortal local builder](https://pumpportal.fun/local-trading-api/trading-api/) +- [PumpPortal Jito bundle fees](https://pumpportal.fun/local-trading-api/jito-bundles/) +- [Pump public IDLs](https://github.com/pump-fun/pump-public-docs/tree/main/idl) +- [Jupiter quote contract](https://developers.jup.ag/docs/swap/v1/get-quote) +- [Jupiter gateway migration and rate allowance](https://developers.jup.ag/docs/portal/migration) +- [Jupiter instruction IDL](https://github.com/jup-ag/jupiter-cpi/blob/main/idl.json) +- [Streamflow withdrawal calculation](https://github.com/streamflow-finance/js-sdk/blob/master/packages/stream/solana/contractUtils.ts) +- [Jayson 5 changelog](https://github.com/tedeh/jayson#changelog-only-notable-milestoneschanges) +- [Unpatched bigint-buffer advisory](https://github.com/advisories/GHSA-3gc7-fjrx-p6mg) diff --git a/README.md b/README.md index f8a593f..c8bafe2 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,12 @@ at all. Everyone else gets silence, not an error. screen already listing the warnings. Before money moves, a copied token is checked against every limit below, each one adjustable under **Copy trading → 🛡 Safety** (defaults shown): - - top 10 wallets hold over **20%** of supply — supply locked for at least - **365 days** (also adjustable) isn't counted as concentration, since a - vesting vault is a whale only until it can actually sell + - top 10 wallets hold over **20%** of supply. Vesting balances are displayed, + but do not reduce concentration: a future stream end does not prove its + tokens cannot already be claimed, or that its vault is a counted holder - the launch wallet still holds over **1%** - the mint or freeze authority is still live, or a Token-2022 mint carries a - transfer hook, transfer fee, or permanent delegate + transfer hook, transfer fee, permanent delegate, or live pause authority - wallets the index reads as one person (an allocation bundled out at creation) hold over **20%** - the developer has minted more than **20** tokens, which reads as a @@ -131,7 +131,9 @@ at all. Everyone else gets silence, not an error. socket can drop, and a dropped socket nobody notices is a copy trader that silently stopped copying — so the sweep continues, finds almost everything already claimed, and catches whatever fell through a reconnect. Every path - claims a signature before it spends, so one transaction is copied once + persists a target-scoped receipt before attempting a trade. Unreadable RPC + receipts retry; a crash after the durable claim can miss a copy, and the bot + favors avoiding repeated spending. Unknown history gaps pause the target - **A wallet that floods is dropped rather than throttled.** Subscribing to a program or an exchange wallet pushes hundreds of transactions a second, and a read per transaction buries the endpoint in rate-limit errors within one — @@ -270,8 +272,10 @@ at all. Everyone else gets silence, not an error. **1. Install** +Use Node 22 or 24. The supported minimum for the project's TSX commands is Node 20.18. + ```bash -npm install +npm ci ``` **2. Configure** @@ -288,6 +292,17 @@ Fill in three things at minimum: | `OWNER_IDS` | [@userinfobot](https://t.me/userinfobot) → your numeric ID | | `SOLANA_RPC_URL` | Helius, QuickNode, or Triton — see the warning below | +Jupiter requests use `https://api.jup.ag`. Optionally set `JUPITER_API_KEY` from +the [Jupiter Developer Platform](https://developers.jup.ag/docs/portal/migration) +for the keyed allowance. Quotes, swaps, prices and token metadata share one +queue: requests start at least 2000 ms apart without a key, or 1000 ms apart +with a key by default. Set `JUPITER_REQUEST_INTERVAL_MS` to match your plan; +zero is intended for offline fixtures. Each request's timeout includes queue +wait, so busy keyless batches can return unavailable prices or metadata, or +fail to obtain a quote. Expired requests leave the queue without sending. +`JUPITER_API_BASE_URL` accepts an HTTPS origin for an intentional gateway proxy; +the key is sent only to that origin, and redirects are refused. + **3. Run** ```bash @@ -515,8 +530,11 @@ Runs offline checks, also enforced on pull requests by GitHub Actions: emit reaches a route — a dead button looks exactly like a slow one — address parsing, concurrency helpers, log redaction - `regressions` — mocked transaction responses, automation failures, vault - migration write failures, private-chat access, expiring confirmations, and - incomplete portfolio reads. These tests never contact Telegram or an RPC. + migration write failures, private-chat access, expiring and changed-context + confirmations, incomplete portfolio reads, copy-event retries and restart + receipts, cancellation during a transaction build, partial-sale cost basis, + Token-2022 safety, builder message validation, and ambiguous history repair. + These tests never contact Telegram or an RPC. Run `npm run check:live` to add the network checks, or run them individually: @@ -600,10 +618,16 @@ src/ ### On pump.fun execution Transactions are built by PumpPortal's *local* API and signed here, with keys -that never leave the process. No third party can move your funds. The upside over -hand-rolling the instructions is that pump.fun changes its program layout without -notice — account ordering, the creator-vault PDA — and that stays their problem -rather than becoming a wave of failed transactions on your side. +that never leave the process. Signing authorizes the entire returned message. +Before signing, the bot checks the sole signer and payer, compute fee ceiling, +recognized venue envelope, and permitted direct SOL/token setup instructions. +Jupiter quotes are also bound to the requested mints, amount, slippage and mode. + +These checks do not decode every swap account or CPI debit. PumpPortal currently +uses an opaque wrapper with no published IDL found in this review; that program +and both builders remain trust dependencies for complete swap intent. Unknown +programs and unresolved lookup-table program or direct-debit accounts are refused. +See [the deeper review](DEEP_REVIEW.md) for evidence and remaining work. Quoting is done independently by reading the bonding curve directly, so the price on screen is the real on-chain price rather than whatever an API reports. diff --git a/REVIEW.md b/REVIEW.md index 27a4f80..57d93c9 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -32,6 +32,11 @@ handling that those safeguards alone did not cover. ## Verification +The integrated deeper pass passes all twelve regression suites, in addition to +279 smoke checks and strict typechecking. Its current live read-only check +returned 24 passed and 2 provider/data failures; see [DEEP_REVIEW.md](DEEP_REVIEW.md). + + Run `npm run check` for strict typechecking, the existing smoke suite, and the new offline regressions. The new tests inject RPC/trading failures, exercise real bot middleware with a fake Telegram API, and inject persistence failures. They @@ -41,35 +46,25 @@ use temporary data and never broadcast transactions. checks**, **27 transaction**, **15 automation**, **7 portfolio**, **9 history reconciliation** cases, and the wallet/auth/persistence regression suite. -The live read-only `npm run netcheck` run returned **25 passed, 1 failed**. +The first-pass live read-only `npm run netcheck` run returned **25 passed, 1 failed**. The failure was the BONK Rugcheck lookup timing out; other Rugcheck probes answered. The public RPC also returned holder-query rate limits. This result does not establish uninterrupted upstream availability or production execution. ## Further improvements +The [deeper review](DEEP_REVIEW.md) implements durable copy receipts and complete +token-account sweeps, and documents the remaining submission-journal work. + + - Persist a transaction journal before submission, then reconcile pending signatures after restart. This would let unknown outcomes recover their ledger entries automatically; the present fix stops replay and asks the operator to check the wallets. -- Separate copy-event receipt from successful transaction parsing. An RPC - parse failure currently consumes the signature and can miss a copy. A - durable queue needs distinct received, parsed, and executed states so that - retrying reads cannot duplicate execution. -- A wallet can hold one mint in several token accounts. A comprehensive sweep - should process every account and report any remainder. - ## Dependency findings -`npm audit --omit=dev` reported **9 findings: 3 high and 6 moderate**, including -inherited package findings. No forced dependency downgrade or major override -was applied. The installed Solana packages still pull the affected dependencies. - -| Advisory | Applicability review | -| --- | --- | -| [bigint-buffer](https://github.com/advisories/GHSA-3gc7-fjrx-p6mg) | No patched release is listed. SPL-token uses fixed-width u64 layouts; the reviewed paths did not demonstrate exploitation. It remains a dependency risk, especially where native bindings are installed. | -| [stream-json](https://github.com/advisories/GHSA-528h-pc64-c93x) | Affects streaming filters; the reviewed web3 client path uses JSON.parse rather than those filters. | -| [uuid](https://github.com/advisories/GHSA-w5hq-g745-h8pq) | Affects v3/v5/v6 output buffers; the reviewed Jayson path calls v4 without a buffer. | - -These are applicability observations, not a clean security audit. Track upstream -compatible fixes and rerun the audit when changing the Solana dependencies. +The first pass reported 9 findings (3 high, 6 moderate). The deeper pass adds a +scoped, compatibility-tested Jayson 5.0.0 override: the audit now reports 3 high +and 0 moderate findings. These are the unpatched bigint-buffer advisory and its +inherited SPL parent findings. See [DEEP_REVIEW.md](DEEP_REVIEW.md) for the evidence, +applicability limits, primary references and remaining work. diff --git a/package-lock.json b/package-lock.json index a576d95..4622d60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "multichain-wallet-bot", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.98.0", @@ -22,7 +23,7 @@ "typescript": "^5.7.2" }, "engines": { - "node": ">=20.11" + "node": ">=20.18" } }, "node_modules/@babel/runtime": { @@ -1288,14 +1289,6 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "engines": { - "node": "> 0.1.90" - } - }, "node_modules/fast-stable-stringify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", @@ -1561,9 +1554,9 @@ } }, "node_modules/jayson": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", - "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-5.0.0.tgz", + "integrity": "sha512-FghxOWlJB5ZPsRsuMF1U4GFHjkJfOEYSlIHpI6Wt6MXIzvqWVo0Kpl7/SMfY36vWggA+b+L09zRGP6m4ROVnKg==", "license": "MIT", "dependencies": { "@types/connect": "^3.4.33", @@ -1572,18 +1565,15 @@ "commander": "^2.20.3", "delay": "^5.0.0", "es6-promisify": "^5.0.0", - "eyes": "^0.1.8", "isomorphic-ws": "^4.0.1", "json-stringify-safe": "^5.0.1", - "stream-json": "^1.9.1", - "uuid": "^8.3.2", "ws": "^7.5.10" }, "bin": { "jayson": "bin/jayson.js" }, "engines": { - "node": ">=8" + "node": ">=20" } }, "node_modules/jayson/node_modules/@types/node": { @@ -1848,21 +1838,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -1981,37 +1956,12 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index def66b1..57ca5a6 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,14 @@ "private": true, "license": "SEE LICENSE IN LICENSE.md", "engines": { - "node": ">=20.11" + "node": ">=20.18" }, "scripts": { "start": "tsx src/index.ts", "dev": "tsx watch src/index.ts", "typecheck": "tsc --noEmit", "smoke": "node --import tsx scripts/smoke.ts", - "regressions": "node --import tsx scripts/transaction-regressions.ts && node --import tsx scripts/copytrade-regressions.ts && node --import tsx scripts/wallet-regressions.ts && node --import tsx scripts/portfolio-regressions.ts && node --import tsx scripts/reconcile-regressions.ts", + "regressions": "node --import tsx scripts/transaction-regressions.ts && node --import tsx scripts/copytrade-regressions.ts && node --import tsx scripts/wallet-regressions.ts && node --import tsx scripts/portfolio-regressions.ts && node --import tsx scripts/reconcile-regressions.ts && node --import tsx scripts/deep-transaction-regressions.ts && node --import tsx scripts/copyevents-regressions.ts && node --import tsx scripts/safety-regressions.ts && node --import tsx scripts/concurrency-regressions.ts && node --import tsx scripts/accounting-regressions.ts && node --import tsx scripts/reconcile-deep-regressions.ts && node --import tsx scripts/client-regressions.ts", "test": "npm run smoke && npm run regressions", "netcheck": "node --import tsx scripts/netcheck.ts", "check": "npm run typecheck && npm test", @@ -33,5 +33,10 @@ "devDependencies": { "@types/node": "^22.10.2", "typescript": "^5.7.2" + }, + "overrides": { + "@solana/web3.js": { + "jayson": "5.0.0" + } } } diff --git a/scripts/accounting-regressions.ts b/scripts/accounting-regressions.ts new file mode 100644 index 0000000..abd3cee --- /dev/null +++ b/scripts/accounting-regressions.ts @@ -0,0 +1,246 @@ +/** Offline accounting checks: quantities, remaining basis, and complete reads. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { ParsedAccountData, PublicKey as PublicKeyType } from '@solana/web3.js'; +import type { WatcherTradeServices } from '../src/services/watcher.js'; +import type { WalletRecord } from '../src/types.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-accounting-')); +process.env.BOT_TOKEN = '123:OFFLINE_TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; +process.env.VAULT_AUTOLOCK_MINUTES = '0'; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; + +const { db } = await import('../src/store/db.js'); +const { entryPrice, exitResult, positionPnl, accountPnl } = await import('../src/services/pnl.js'); +const { rpc, getSplBalances, getMintBalances, getMintDecimals, WSOL_MINT, sendSplToken } = await import('../src/chains/solana.js'); +const { measureTokensGained, measureTokensSold, isFreshEntry, batchSweepToken } = await import('../src/trade/engine.js'); +const { buildPortfolio, aggregateToken, listPositions } = await import('../src/services/portfolio.js'); +const { fire, runDueDca, entryPriceSol, ruleTriggered } = await import('../src/services/watcher.js'); +const { initVaultWithKeyfile, lockVault } = await import('../src/store/vault.js'); +const { generateSolanaWallet } = await import('../src/store/wallets.js'); +const { clearPriceCache } = await import('../src/services/prices.js'); +const { PublicKey, VersionedTransaction } = await import('@solana/web3.js'); +const { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, AccountLayout } = await import('@solana/spl-token'); +const bs58 = (await import('bs58')).default; +const client = rpc(); +const original = { + tokens: client.getParsedTokenAccountsByOwner, multiple: client.getMultipleAccountsInfo, + account: client.getAccountInfo, blockhash: client.getLatestBlockhash, + send: client.sendRawTransaction, statuses: client.getSignatureStatuses, fetch: globalThis.fetch, +}; +let passed = 0; +const check = (name: string) => { passed++; console.log(` ✓ ${name}`); }; +const approx = (actual: number | null, expected: number) => assert.ok(actual !== null && Math.abs(actual - expected) < 1e-12, `${actual} != ${expected}`); +const mint = PublicKey.unique().toBase58(); +let decimals = 9; +let entries: Array<{ address: string; account: PublicKeyType; raw: bigint; ui: number | null; program: PublicKeyType; confidential?: boolean }> = []; +let failToken22 = false; + +function parsed(entry: typeof entries[number]) { + return { + pubkey: entry.account, + account: { owner: entry.program, lamports: 2_039_280, executable: false, rentEpoch: 0, + data: { program: entry.program.equals(TOKEN_PROGRAM_ID) ? 'spl-token' : 'spl-token-2022', space: 165, + parsed: { info: { mint, owner: entry.address, + tokenAmount: { amount: entry.raw.toString(), decimals, uiAmount: entry.ui, uiAmountString: String(Number(entry.raw) / 10 ** decimals) }, + ...(entry.confidential ? { extensions: [{ extension: 'confidentialTransferAccount' }] } : {}), + } }, + } as ParsedAccountData, + }, + }; +} + +try { + db.recordBuy('partial', { solSpent: 1, fills: 1, tokensBought: 100, freshEntry: true }); + db.recordSell('partial', 0.9, 1, 90); + approx(entryPrice(db.position('partial')), 0.01); + db.recordBuy('partial', { solSpent: 1, fills: 1, tokensBought: 10 }); + approx(entryPrice(db.position('partial')), 1.1 / 20); + approx(exitResult(db.position('partial'), 20, 1.1)!.profitSol, 0); + assert.equal(ruleTriggered({ id: 'stop', mint: 'partial', kind: 'stop_loss', triggerPct: -50, + sellPercent: 100, enabled: true, createdAt: 1 }, 0.02, entryPrice(db.position('partial'))), true); + check('partial sells retire basis before averaging in and preserve correct exits and stops'); + + db.recordBuy('unknown-buy', { solSpent: 1, fills: 1, tokensBought: 0 }); + db.recordBuy('unknown-buy', { solSpent: 1, fills: 1, tokensBought: 100 }); + assert.equal(entryPrice(db.position('unknown-buy')), null); + db.recordBuy('unknown-sale', { solSpent: 1, fills: 1, tokensBought: 100 }); + db.recordSell('unknown-sale', 0.5, 1); + assert.equal(entryPrice(db.position('unknown-sale')), null); + assert.equal(exitResult(db.position('unknown-sale'), 10, 1), null); + db.recordBuy('unknown-sale', { solSpent: 2, fills: 1, tokensBought: 10, freshEntry: true }); + approx(entryPrice(db.position('unknown-sale')), 0.2); + const legacy = { mint: 'legacy', investedSol: 1, tokensBought: 100, realisedSol: 0.5, + buyFills: 1, sellFills: 1, firstBuyAt: 1, lastTradeAt: 2 }; + assert.equal(entryPrice(legacy), null); + check('unknown quantities and legacy sales cannot become reliable basis through lifetime fallbacks'); + + db.recordBuy('fees', { solSpent: 1, costSol: 1.02, fills: 1, tokensBought: 100 }); + const feePos = db.position('fees')!; + approx(positionPnl(feePos, 1).netSol, -0.02); + approx(accountPnl([feePos], new Map([['fees', 1]]), 100).netSol, -0.02); + check('position and account profit both include measured fees and rent'); + + const ledgerBefore = structuredClone(db.positions()); + for (const patch of [{ solSpent: NaN }, { solSpent: Infinity }, { fills: Infinity }, { fills: 1.5 }, + { tokensBought: NaN }, { tokensBought: -1 }, { costSol: Infinity }, { costSol: -1 }, { decimals: 256 }, { decimals: 1.5 }]) { + db.recordBuy('fees', { solSpent: 1, fills: 1, tokensBought: 100, ...patch }); + } + for (const [sol, fills, quantity] of [[NaN, 1, 1], [Infinity, 1, 1], [1, Infinity, 1], [1, 1.5, 1], [1, 1, NaN], [1, 1, -1]]) { + db.recordSell('fees', sol!, fills!, quantity); + } + assert.deepEqual(db.positions(), ledgerBefore); + db.recordBuy('overflow', { solSpent: 1e308, fills: 1, tokensBought: 1 }); + db.recordBuy('overflow', { solSpent: 1e308, fills: 1, tokensBought: 1 }); + assert.equal(db.position('overflow')!.investedSol, 1e308); + check('malformed financial inputs and overflowing sums cannot corrupt persisted amounts'); + + initVaultWithKeyfile(); + const wallet = generateSolanaWallet('accounting-wallet'); + const other: WalletRecord = { ...wallet, id: 'other', address: PublicKey.unique().toBase58() }; + client.getParsedTokenAccountsByOwner = async (owner, filter) => { + if ('programId' in filter && filter.programId.equals(TOKEN_2022_PROGRAM_ID) && failToken22) throw new Error('offline Token-2022 outage'); + return { context: { slot: 1 }, value: entries.filter((e) => e.address === owner.toBase58() && + ('mint' in filter || e.program.equals(filter.programId))).map(parsed) }; + }; + client.getMultipleAccountsInfo = async (keys) => keys.map(() => ({ owner: PublicKey.default, + data: Buffer.alloc(0), lamports: 1e9, executable: false, rentEpoch: 0 })); + client.getAccountInfo = async () => { + const data = Buffer.alloc(82); data[44] = decimals; data[45] = 1; + return { owner: TOKEN_PROGRAM_ID, data, lamports: 1, executable: false, rentEpoch: 0 }; + }; + globalThis.fetch = async () => new Response(JSON.stringify({ [WSOL_MINT]: { usdPrice: 100 }, [mint]: { usdPrice: 5 } }), { status: 200 }); + entries = [ + { address: wallet.address, account: PublicKey.unique(), raw: 1_000_000_000n, ui: null, program: TOKEN_PROGRAM_ID }, + { address: wallet.address, account: PublicKey.unique(), raw: 2_000_000_000n, ui: 200, program: TOKEN_PROGRAM_ID }, + ]; + assert.equal(await getMintDecimals(mint), 9); + const holdings = await getSplBalances(wallet.address); + assert.deepEqual(holdings.map((h) => h.amount), [1, 2]); + assert.equal((await getMintBalances([wallet.address], mint)).get(wallet.address), 3_000_000_000n); + const portfolio = await buildPortfolio(); + assert.equal(aggregateToken(portfolio, mint).totalAmount, 3); + assert.equal(aggregateToken(portfolio, mint).totalUsd, 15); + assert.equal(listPositions(portfolio)[0]!.walletCount, 1); + check('all mint accounts are summed once per wallet and nullable or scaled UI floats cannot alter accounting units'); + + const before = new Map([[wallet.address, 1_000_000_000n], [other.address, 100_000_000_000n]]); + assert.equal(await measureTokensGained([wallet.address], mint, before, 9), 2); + entries = [{ ...entries[0]!, raw: 500_000_000n }]; + assert.equal(await measureTokensSold([wallet.address], mint, before, 9), 0.5); + assert.equal(await measureTokensGained([wallet.address], mint, new Map(), undefined), 0); + assert.equal(await measureTokensSold([wallet.address], mint, before, undefined), 0); + assert.equal(isFreshEntry(new Map([[other.address, 1n]])), false); + check('measurements use actual decimals and only the selected wallet delta even with account-wide before balances'); + + db.recordBuy(mint, { solSpent: 1, fills: 1, tokensBought: 100, freshEntry: true, decimals: 9 }); + const services: WatcherTradeServices = { + selectWallets: () => [wallet], allWallets: () => [wallet, other], + getMintBalances: async (addresses) => { assert.ok(addresses.includes(other.address)); return new Map([[other.address, 100_000_000_000n]]); }, + getMintDecimals: async () => 9, + measureTokensGained: async (_addresses, _mint, _before, actualDecimals) => { assert.equal(actualDecimals, 9); return 1; }, + measureTokensSold: async () => 1, + batchPumpTrade: async () => ({ results: [{ walletId: wallet.id, address: wallet.address, label: wallet.label, + ok: true, signature: 'offline-fill' }], succeeded: 1, failed: 0, startedAt: 1, finishedAt: 2, solSpent: 1 }), + }; + const rule = { id: 'nine-decimal-limit', mint, kind: 'limit_buy' as const, triggerPct: 0, + sellPercent: 100, buySol: 1, triggerPriceSol: 1, enabled: true, createdAt: 1 }; + db.addRule(rule); await fire(rule, 1, async () => {}, services); + approx(entryPriceSol(mint), 2 / 101); + db.addDcaPlan({ id: 'nine-decimal-dca', mint, buySol: 1, roundsDone: 0, roundsTotal: 1, + intervalMinutes: 1, nextRunAt: 0, enabled: true, createdAt: 1 }); + await runDueDca(async () => {}, services); + approx(entryPriceSol(mint), 3 / 102); + assert.equal(db.position(mint)!.decimals, 9); + check('limit and DCA buys read actual decimals and preserve another wallet group’s open basis'); + + const uncertainMint = PublicKey.unique().toBase58(); + const uncertainRule = { ...rule, id: 'mixed-confirmation-limit', mint: uncertainMint, firedAt: undefined }; + db.addRule(uncertainRule); + const uncertainServices: WatcherTradeServices = { ...services, + getMintBalances: async () => new Map(), + measureTokensGained: async (addresses, _mint, _before, actualDecimals) => { + assert.deepEqual(addresses, [wallet.address]); assert.equal(actualDecimals, 9); return 1; + }, + batchPumpTrade: async () => ({ results: [ + { walletId: wallet.id, address: wallet.address, label: wallet.label, ok: true, signature: 'confirmed' }, + { walletId: other.id, address: other.address, label: other.label, ok: false, signature: 'pending', confirmationUnknown: true }, + ], succeeded: 1, failed: 1, startedAt: 1, finishedAt: 2 }), + }; + let note = ''; + await fire(uncertainRule, 1, async (text) => { note = text; }, uncertainServices); + assert.equal(db.position(uncertainMint)!.tokensBought, 1); + assert.equal(db.position(uncertainMint)!.investedSol, 1); + assert.equal(entryPriceSol(uncertainMint), null); + assert.match(note, /Entry basis and proceeds are unknown/); + check('mixed confirmations count only confirmed token deltas and keep the open basis unknown'); + + failToken22 = true; clearPriceCache(); + const partial = await buildPortfolio(); + assert.match(partial.errors.join(' '), /Token-2022 outage/); + failToken22 = false; + const validEntries = entries; + entries = [{ ...entries[0]!, raw: -1n }]; + await assert.rejects(getMintBalances([wallet.address], mint), /invalid raw amount/); + await assert.rejects(getSplBalances(wallet.address), /invalid raw amount/); + entries = validEntries; + check('malformed raw RPC balances cannot reduce exposure or valuation'); + entries = [{ ...entries[0]!, raw: 0n, confidential: true, program: TOKEN_2022_PROGRAM_ID }]; + await assert.rejects(getSplBalances(wallet.address), /Confidential/); + await assert.rejects(getMintBalances([wallet.address], mint), /Confidential/); + check('Token-2022 outages and encrypted balances remain unknown instead of becoming a complete zero valuation'); + + entries = [ + { address: wallet.address, account: PublicKey.unique(), raw: 1_000_000_000n, ui: 1, program: TOKEN_PROGRAM_ID }, + { address: wallet.address, account: PublicKey.unique(), raw: 2_000_000_000n, ui: 2, program: TOKEN_PROGRAM_ID }, + ]; + client.getLatestBlockhash = async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: 1 }); + client.getSignatureStatuses = async () => ({ context: { slot: 1 }, value: [{ slot: 1, + confirmations: 1, err: null, confirmationStatus: 'confirmed' }] }); + const sweptSources: string[] = []; + client.sendRawTransaction = async (raw) => { + const tx = VersionedTransaction.deserialize(Uint8Array.from(raw)); + const keys = tx.message.staticAccountKeys; + for (const ix of tx.message.compiledInstructions) { + if (keys[ix.programIdIndex]!.equals(TOKEN_PROGRAM_ID) && ix.data[0] === 9) sweptSources.push(keys[ix.accountKeyIndexes[0]!]!.toBase58()); + } + return bs58.encode(tx.signatures[0]!); + }; + const swept = await batchSweepToken([wallet], mint, PublicKey.unique().toBase58()); + assert.equal(swept.succeeded, 2); + assert.deepEqual(sweptSources, entries.map((e) => e.account.toBase58())); + check('one token sweep moves and closes every matching account including non-ATAs'); + + const source = PublicKey.unique(); + const extended = Buffer.alloc(178); + AccountLayout.encode({ mint: new PublicKey(mint), owner: new PublicKey(wallet.address), amount: 1_000_000_000n, + delegateOption: 0, delegate: PublicKey.default, state: 1, isNativeOption: 0, isNative: 0n, + delegatedAmount: 0n, closeAuthorityOption: 0, closeAuthority: PublicKey.default }, extended); + extended[165] = 2; extended.writeUInt16LE(2, 166); extended.writeUInt16LE(8, 168); extended.writeBigUInt64LE(10n, 170); + client.getAccountInfo = async () => ({ owner: TOKEN_2022_PROGRAM_ID, data: extended, lamports: 1, executable: false, rentEpoch: 0 }); + let harvested = false; + client.sendRawTransaction = async (raw) => { + const tx = VersionedTransaction.deserialize(Uint8Array.from(raw)); + harvested = tx.message.compiledInstructions.some((ix) => tx.message.staticAccountKeys[ix.programIdIndex]!.equals(TOKEN_2022_PROGRAM_ID) && ix.data[0] === 26 && ix.data[1] === 4); + return bs58.encode(tx.signatures[0]!); + }; + const { solanaKeypair } = await import('../src/store/wallets.js'); + await sendSplToken(solanaKeypair(wallet), PublicKey.unique().toBase58(), mint, 1_000_000_000n, + decimals, 0, TOKEN_2022_PROGRAM_ID.toBase58(), true, source.toBase58()); + assert.equal(harvested, true); + check('withheld transfer fees are harvested before closing a Token-2022 source account'); + console.log(`\n${passed} offline accounting regressions passed.`); +} finally { + client.getParsedTokenAccountsByOwner = original.tokens; + client.getMultipleAccountsInfo = original.multiple; + client.getAccountInfo = original.account; + client.getLatestBlockhash = original.blockhash; + client.sendRawTransaction = original.send; + client.getSignatureStatuses = original.statuses; + globalThis.fetch = original.fetch; + lockVault(); fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/client-regressions.ts b/scripts/client-regressions.ts new file mode 100644 index 0000000..53ecf13 --- /dev/null +++ b/scripts/client-regressions.ts @@ -0,0 +1,160 @@ +/** Offline gateway and startup configuration checks. Every transport is stubbed. */ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +process.env.BOT_TOKEN = '123:OFFLINE'; +process.env.OWNER_IDS = '1'; +process.env.JUPITER_API_KEY = ''; +process.env.JUPITER_API_BASE_URL = 'https://api.jup.ag'; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; + +const { createJupiterClient, fetchJupiterJson } = await import('../src/services/jupiter-client.js'); +const { endpoints } = await import('../src/config.js'); +const originalFetch = globalThis.fetch; +let passed = 0; +function ok(name: string): void { passed++; console.log(` ✓ ${name}`); } + +const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + BOT_TOKEN: '123:OFFLINE', OWNER_IDS: '1', VAULT_AUTOLOCK_MINUTES: '0', + DEFAULT_SLIPPAGE_PERCENT: '', DEFAULT_PRIORITY_FEE_SOL: '', DEFAULT_EXECUTION_MODE: '', + EXECUTION_CONCURRENCY: '', JITO_TIP_SOL: '', MAX_BUY_SOL_PER_WALLET: '', REQUIRE_CONFIRMATION: '', + JUPITER_API_BASE_URL: '', JUPITER_API_KEY: '', JUPITER_REQUEST_INTERVAL_MS: '', +}; +const configUrl = new URL('../src/config.ts', import.meta.url).href; +function configProbe(overrides: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', + `try { const { config, endpoints } = await import(${JSON.stringify(configUrl)}); console.log(JSON.stringify({ config, endpoints })); } catch (err) { console.error(err.message); process.exitCode = 1; }`, + ], { env: { ...baseEnv, ...overrides }, encoding: 'utf8', timeout: 10_000 }); +} + +try { + const defaults = configProbe(); + assert.equal(defaults.status, 0, defaults.stderr); + const parsed = JSON.parse(defaults.stdout) as { config: { jupiter: { baseUrl: string; requestIntervalMs: number } }; endpoints: Record }; + assert.equal(parsed.config.jupiter.baseUrl, 'https://api.jup.ag'); + assert.equal(parsed.config.jupiter.requestIntervalMs, 2_000); + assert.equal(parsed.endpoints.jupiterQuote, 'https://api.jup.ag/swap/v1/quote'); + assert.equal(parsed.endpoints.jupiterTokens, 'https://api.jup.ag/tokens/v2/search'); + const keyed = configProbe({ JUPITER_API_KEY: 'offline-key' }); + assert.equal(keyed.status, 0, keyed.stderr); + assert.equal(JSON.parse(keyed.stdout).config.jupiter.requestIntervalMs, 1_000); + ok('current gateway defaults honor the keyless and keyed rate allowances'); + + const invalid: Array<[string, string]> = [ + ['DEFAULT_SLIPPAGE_PERCENT', '100'], ['DEFAULT_SLIPPAGE_PERCENT', 'Infinity'], + ['DEFAULT_PRIORITY_FEE_SOL', '-1'], ['DEFAULT_PRIORITY_FEE_SOL', 'NaN'], + ['DEFAULT_PRIORITY_FEE_SOL', '1e300'], ['DEFAULT_PRIORITY_FEE_SOL', '10000000'], + ['JITO_TIP_SOL', '-0.1'], ['MAX_BUY_SOL_PER_WALLET', '0'], ['MAX_BUY_SOL_PER_WALLET', '1e-10'], + ['MAX_BUY_SOL_PER_WALLET', 'not-a-number'], ['EXECUTION_CONCURRENCY', '0'], + ['EXECUTION_CONCURRENCY', '1.5'], ['EXECUTION_CONCURRENCY', '1001'], + ['DEFAULT_EXECUTION_MODE', 'paralell'], ['REQUIRE_CONFIRMATION', 'tru'], + ['OWNER_IDS', '1,invalid'], ['OWNER_IDS', '9007199254740992'], + ['VAULT_AUTOLOCK_MINUTES', '-1'], ['JUPITER_REQUEST_INTERVAL_MS', '-1'], + ['JUPITER_REQUEST_INTERVAL_MS', '1.5'], ['JUPITER_REQUEST_INTERVAL_MS', '60001'], + ['JUPITER_API_BASE_URL', 'http://api.jup.ag'], + ['JUPITER_API_BASE_URL', 'https://user:password@api.jup.ag'], + ['JUPITER_API_BASE_URL', 'https://api.jup.ag/private'], + ['JUPITER_API_BASE_URL', 'https://api.jup.ag/?key=oops'], + ['JUPITER_API_KEY', 'key\ninjection'], + ]; + for (const [field, value] of invalid) { + const probe = configProbe({ [field]: value }); + assert.equal(probe.status, 1, `${field}=${value} was accepted: ${probe.stdout} ${probe.stderr}`); + assert.ok(probe.stderr.includes(field), `${field} should be named in the startup error.`); + } + ok('invalid money, slippage, concurrency, modes, owners, booleans and gateway configuration fail startup'); + + const zeros = configProbe({ DEFAULT_PRIORITY_FEE_SOL: '0', JITO_TIP_SOL: '0', DEFAULT_SLIPPAGE_PERCENT: '0', + MAX_BUY_SOL_PER_WALLET: '0.000000001', DEFAULT_EXECUTION_MODE: 'bundle', REQUIRE_CONFIRMATION: 'false', + JUPITER_REQUEST_INTERVAL_MS: '0', OWNER_IDS: '1,2,1' }); + assert.equal(zeros.status, 0, zeros.stderr); + const zeroConfig = JSON.parse(zeros.stdout).config; + assert.equal(zeroConfig.trading.priorityFeeSol, 0); + assert.equal(zeroConfig.trading.jitoTipSol, 0); + assert.deepEqual(zeroConfig.ownerIds, [1, 2]); + ok('explicit zero fees, zero slippage, one-lamport cap and offline interval remain valid'); + + const observations: Array<{ url: string; headers: Headers; redirect: RequestInit['redirect']; at: number }> = []; + globalThis.fetch = async (input, init) => { + observations.push({ url: String(input), headers: new Headers(init?.headers), redirect: init?.redirect, at: performance.now() }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + const authenticated = createJupiterClient({ baseUrl: 'https://proxy.example:8443', apiKey: 'offline-key', requestIntervalMs: 0 }); + await authenticated('https://proxy.example:8443/swap/v1/quote', { + headers: [['Content-Type', 'application/json'], ['x-api-key', 'caller-key']], redirect: 'follow', + }); + assert.equal(observations[0]!.headers.get('x-api-key'), 'offline-key'); + assert.equal(observations[0]!.headers.get('content-type'), 'application/json'); + assert.equal(observations[0]!.redirect, 'error'); + await assert.rejects(authenticated('https://other.example/swap/v1/quote'), /configured HTTPS origin/); + await assert.rejects(authenticated('http://proxy.example:8443/swap/v1/quote'), /configured HTTPS origin/); + await assert.rejects(authenticated('https://user:password@proxy.example:8443/swap/v1/quote'), /configured HTTPS origin/); + assert.equal(observations.length, 1); + await fetchJupiterJson(endpoints.jupiterPrice, { headers: { 'x-api-key': 'stale-key' } }); + assert.equal(observations[1]!.headers.has('x-api-key'), false); + ok('API keys stay on the configured HTTPS origin, caller headers cannot replace them, redirects are refused'); + + globalThis.fetch = async () => new Response('rejected key offline-key; repeated offline-key', { status: 401 }); + await assert.rejects(authenticated('https://proxy.example:8443/swap/v1/quote'), (err: unknown) => + err instanceof Error && /HTTP 401/.test(err.message) && err.message.includes('[redacted]') && !err.message.includes('offline-key')); + globalThis.fetch = async () => new Response('offline-key', { status: 200 }); + await assert.rejects(authenticated('https://proxy.example:8443/swap/v1/quote'), (err: unknown) => + err instanceof SyntaxError && !`${err.message}\n${err.stack}`.includes('offline-key')); + globalThis.fetch = async (input, init) => { + observations.push({ url: String(input), headers: new Headers(init?.headers), redirect: init?.redirect, at: performance.now() }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + ok('HTTP and malformed JSON error bodies cannot echo the API key into application logs'); + + observations.length = 0; + const paced = createJupiterClient({ baseUrl: 'https://api.jup.ag', apiKey: '', requestIntervalMs: 35 }); + await Promise.all([ + paced(endpoints.jupiterQuote), paced(endpoints.jupiterSwap), paced(endpoints.jupiterPrice), paced(endpoints.jupiterTokens), + ]); + assert.equal(observations.length, 4); + for (let i = 1; i < observations.length; i++) { + assert.ok(observations[i]!.at - observations[i - 1]!.at >= 33, 'different APIs must share the dispatch interval'); + } + ok('quote, swap, price and token calls share one paced dispatch queue'); + + observations.length = 0; + const expiry = createJupiterClient({ baseUrl: 'https://api.jup.ag', apiKey: '', requestIntervalMs: 60 }); + await expiry(endpoints.jupiterQuote); + const expired = [1, 2, 3].map(() => assert.rejects(expiry(endpoints.jupiterSwap, { timeoutMs: 15 }), (err: unknown) => + err instanceof Error && err.name === 'TimeoutError')); + const live = expiry(endpoints.jupiterPrice, { timeoutMs: 500 }); + await Promise.all([...expired, live]); + assert.deepEqual(observations.map((o) => o.url), [endpoints.jupiterQuote, endpoints.jupiterPrice]); + assert.ok(observations[1]!.at - observations[0]!.at < 140, 'expired queue entries must not consume future slots'); + ok('queue wait consumes the timeout budget and expired requests never dispatch or reserve slots'); + + observations.length = 0; + const cancel = createJupiterClient({ baseUrl: 'https://api.jup.ag', apiKey: '', requestIntervalMs: 40 }); + await cancel(endpoints.jupiterQuote); + const controller = new AbortController(); + const cancellation = assert.rejects(cancel(endpoints.jupiterSwap, { signal: controller.signal }), (err: unknown) => + err instanceof Error && err.name === 'AbortError'); + controller.abort(); + await Promise.all([cancellation, cancel(endpoints.jupiterTokens)]); + assert.deepEqual(observations.map((o) => o.url), [endpoints.jupiterQuote, endpoints.jupiterTokens]); + ok('caller cancellation removes queued work without consuming a dispatch slot'); + + const unpaced = createJupiterClient({ baseUrl: 'https://api.jup.ag', apiKey: '', requestIntervalMs: 0 }); + globalThis.fetch = async () => new Promise(() => {}); + await assert.rejects(unpaced(endpoints.jupiterPrice, { timeoutMs: 15 }), (err: unknown) => err instanceof Error && err.name === 'TimeoutError'); + globalThis.fetch = async () => ({ ok: true, status: 200, text: () => new Promise(() => {}) }) as Response; + await assert.rejects(unpaced(endpoints.jupiterPrice, { timeoutMs: 15 }), (err: unknown) => err instanceof Error && err.name === 'TimeoutError'); + ok('the same deadline covers stalled fetches and stalled response bodies'); + + globalThis.fetch = async () => new Response('rate limited', { status: 429 }); + await assert.rejects(unpaced(endpoints.jupiterQuote), /HTTP 429/); + globalThis.fetch = async () => new Response('{broken JSON', { status: 200 }); + await assert.rejects(unpaced(endpoints.jupiterTokens), SyntaxError); + await assert.rejects(unpaced(endpoints.jupiterTokens, { timeoutMs: 0 }), /timeout/); + ok('HTTP, malformed JSON and invalid request budgets remain visible failures'); +} finally { + globalThis.fetch = originalFetch; +} + +console.log(`\n${passed} offline client and configuration regressions passed.`); diff --git a/scripts/concurrency-regressions.ts b/scripts/concurrency-regressions.ts new file mode 100644 index 0000000..e6ff227 --- /dev/null +++ b/scripts/concurrency-regressions.ts @@ -0,0 +1,281 @@ +/** Offline coordination regressions. All transaction builds and RPCs are mocked. */ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import bs58 from 'bs58'; +import { + Keypair, PublicKey, SystemProgram, TransactionInstruction, TransactionMessage, VersionedTransaction, +} from '@solana/web3.js'; +import type { Context } from 'grammy'; +import type { AutoRule, DcaPlan } from '../src/store/db.js'; +import type { WatcherTradeServices } from '../src/services/watcher.js'; +import type { WalletRecord } from '../src/types.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-coordination-')); +Object.assign(process.env, { + BOT_TOKEN: '123:OFFLINE', OWNER_IDS: '1,2', DATA_DIR: dataDir, + VAULT_AUTOLOCK_MINUTES: '0', REQUIRE_CONFIRMATION: 'true', + JUPITER_REQUEST_INTERVAL_MS: '0', + SOLANA_RPC_URL: 'http://127.0.0.1:8899', SOLANA_SEND_RPC_URL: 'http://127.0.0.1:8899', +}); + +const { db } = await import('../src/store/db.js'); +const vault = await import('../src/store/vault.js'); +const wallets = await import('../src/store/wallets.js'); +const { rpc } = await import('../src/chains/solana.js'); +const { batchPumpTrade } = await import('../src/trade/engine.js'); +const { fire, runDueDca } = await import('../src/services/watcher.js'); +const { withExecution, withExecutionMaintenance, ExecutionCancelledError } = await import('../src/services/execution.js'); +const { session, stageConfirmation, takeConfirmation, setPending } = await import('../src/bot/session.js'); +const { executeFactoryReset, RESET_PHRASE } = await import('../src/bot/handlers/core.js'); +const { promptFund } = await import('../src/bot/handlers/trade.js'); +const { promptRemove } = await import('../src/bot/handlers/wallets.js'); +const client = rpc(); +const originalFetch = globalThis.fetch; +let passed = 0; +const check = (name: string) => { passed++; console.log(` ✓ ${name}`); }; +const deferred = () => { + let resolve!: () => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +}; +const ctx = (id = 1): Context => ({ + from: { id }, reply: async () => ({}), answerCallbackQuery: async () => true, +} as unknown as Context); +const request = () => ({ + action: 'buy' as const, mint: Keypair.generate().publicKey.toBase58(), amount: 0.01, + denominatedInSol: true, slippagePercent: 5, priorityFeeSol: 0.00005, pool: 'pump' as const, +}); +function builtTrade(wallet: string): VersionedTransaction { + const swap = new TransactionInstruction({ + programId: new PublicKey('6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P'), + keys: [{ pubkey: new PublicKey(wallet), isSigner: true, isWritable: true }], + data: Buffer.concat([crypto.createHash('sha256').update('global:buy').digest().subarray(0, 8), Buffer.alloc(16)]), + }); + return new VersionedTransaction(new TransactionMessage({ + payerKey: new PublicKey(wallet), recentBlockhash: Keypair.generate().publicKey.toBase58(), instructions: [swap], + }).compileToV0Message()); +} +function mockRpc(balance: () => number): void { + client.getMultipleAccountsInfo = async (keys) => keys.map(() => ({ + data: Buffer.alloc(0), executable: false, lamports: balance(), owner: SystemProgram.programId, rentEpoch: 0, + })); + client.getSignatureStatuses = async () => ({ context: { slot: 1 }, value: [{ + slot: 1, confirmations: 1, err: null, confirmationStatus: 'confirmed', + }] }); +} +function fakeServices(): WatcherTradeServices { + const wallet = { id: 'dummy', address: 'dummy', label: 'dummy' } as WalletRecord; + return { + selectWallets: () => [wallet], allWallets: () => [wallet], getMintDecimals: async () => 6, + getMintBalances: async () => new Map([[wallet.address, 100n]]), + batchPumpTrade: async () => ({ results: [], succeeded: 0, failed: 0, startedAt: 1, finishedAt: 2 }), + measureTokensGained: async () => 1, measureTokensSold: async () => 1, + }; +} + +try { + vault.initVaultWithKeyfile(); + db.updateSettings({ priorityFeeMode: 'fixed', executionMode: 'parallel' }); + + { + const started = deferred(); const release = deferred(); const order: string[] = []; + const first = withExecution(async () => { + order.push('before'); started.resolve(); await release.promise; + await withExecution(async () => { order.push('nested'); }); + order.push('ledger'); + }); + await started.promise; + const second = withExecution(async () => { order.push('second-before'); }); + await Promise.resolve(); assert.deepEqual(order, ['before']); + release.resolve(); await Promise.all([first, second]); + assert.deepEqual(order, ['before', 'nested', 'ledger', 'second-before']); + check('complete operations serialize through bookkeeping and nested calls are reentrant'); + } + + { + const trigger = deferred(); let detached!: Promise; let ran = false; + await withExecution(async () => { + detached = trigger.promise.then(() => withExecution(async () => { ran = true; })); + }); + const rejected = assert.rejects(detached, ExecutionCancelledError); trigger.resolve(); await rejected; + assert.equal(ran, false); + check('detached callbacks cannot reuse a completed operation to bypass the queue'); + } + + { + const w = wallets.generateSolanaWallet('reserve'); + let balance = 24_200_000; let builds = 0; let sends = 0; + mockRpc(() => balance); + const entered = deferred(); const release = deferred(); + globalThis.fetch = async (_url, init) => { + builds++; entered.resolve(); await release.promise; + return new Response(builtTrade(JSON.parse(String(init?.body)).publicKey).serialize()); + }; + client.sendRawTransaction = async (raw) => { + sends++; balance -= 12_094_280; + return bs58.encode(VersionedTransaction.deserialize(Uint8Array.from(raw)).signatures[0]!); + }; + const first = batchPumpTrade([w], request()); await entered.promise; + const second = batchPumpTrade([w], request()); + await Promise.resolve(); assert.equal(builds, 1); + release.resolve(); const [a, b] = await Promise.all([first, second]); + assert.equal(sends, 1); assert.equal(a.solSpent, 0.01209428); + assert.equal(b.solSpent, undefined); assert.match(b.results[0]!.detail ?? '', /unfunded/); + assert.ok(balance >= 110_000); + check('queued buys recheck funds and cannot share or double-count the exit reserve'); + } + + { + const a = wallets.generateSolanaWallet('parallel-a'); const b = wallets.generateSolanaWallet('parallel-b'); + mockRpc(() => 1e9); let builds = 0; + const both = deferred(); const release = deferred(); + globalThis.fetch = async (_url, init) => { + if (++builds === 2) both.resolve(); await release.promise; + return new Response(builtTrade(JSON.parse(String(init?.body)).publicKey).serialize()); + }; + client.sendRawTransaction = async (raw) => bs58.encode(VersionedTransaction.deserialize(Uint8Array.from(raw)).signatures[0]!); + const batch = batchPumpTrade([a, b], request()); await both.promise; + assert.equal(builds, 2); release.resolve(); assert.equal((await batch).succeeded, 2); + check('one batch retains concurrent wallet execution'); + } + + { + const a = wallets.generateSolanaWallet('known-fill'); const b = wallets.generateSolanaWallet('unknown-fill'); + let balance = 1e9; let sends = 0; mockRpc(() => balance); + globalThis.fetch = async (_url, init) => new Response(builtTrade(JSON.parse(String(init?.body)).publicKey).serialize()); + client.sendRawTransaction = async (raw) => { + balance -= 10_000_000; + if (++sends === 2) throw new Error('Response lost after dispatch'); + return bs58.encode(VersionedTransaction.deserialize(Uint8Array.from(raw)).signatures[0]!); + }; + const req = request(); + db.recordBuy(req.mint, { solSpent: 0.01, fills: 1, tokensBought: 100, freshEntry: true }); + const result = await batchPumpTrade([a, b], req); + assert.ok(result.results.some((r) => r.confirmationUnknown)); assert.equal(result.solSpent, undefined); + assert.equal(db.position(req.mint)!.basisKnown, false); + check('mixed confirmed and uncertain fills cannot publish a combined cost or known basis'); + } + + { + const services = fakeServices(); let trades = 0; + services.batchPumpTrade = async () => { trades++; return { results: [], succeeded: 0, failed: 0, startedAt: 1, finishedAt: 2 }; }; + const rule: AutoRule = { id: 'cleared-rule', mint: 'mint', kind: 'stop_loss', triggerPct: -20, sellPercent: 100, enabled: true, createdAt: 1 }; + db.addRule(rule); const snapshot = db.activeRules().find((r) => r.id === rule.id)!; db.removeRule(rule.id); + await fire(snapshot, 1, async () => {}, services); assert.equal(trades, 0); + const entered = deferred(); const release = deferred(); + services.getMintBalances = async () => { entered.resolve(); await release.promise; return new Map(); }; + const plan: DcaPlan = { id: 'cleared-dca', mint: 'mint', buySol: 0.01, roundsDone: 0, roundsTotal: 2, intervalMinutes: 60, nextRunAt: 0, enabled: true, createdAt: 1 }; + db.addDcaPlan(plan); const pending = runDueDca(async () => {}, services); await entered.promise; + db.removeDcaPlan(plan.id); release.resolve(); await pending; assert.equal(trades, 0); + const disabledRule = { ...rule, id: 'disabled-during-read' }; db.addRule(disabledRule); + const reading = deferred(); const continueRead = deferred(); + services.getMintBalances = async () => { reading.resolve(); await continueRead.promise; return new Map([['dummy', 100n]]); }; + const pendingRule = fire(disabledRule, 1, async () => {}, services); await reading.promise; + db.updateRule(disabledRule.id, { enabled: false }); continueRead.resolve(); await pendingRule; + assert.equal(trades, 0); + check('removed rule snapshots and DCA removed during a balance read cannot trade'); + } + + { + const w = wallets.generateSolanaWallet('disable-before-send'); mockRpc(() => 1e9); + client.getAccountInfo = async () => null; + const entered = deferred(); const release = deferred(); let sends = 0; + globalThis.fetch = async (_url, init) => { + entered.resolve(); await release.promise; + return new Response(builtTrade(JSON.parse(String(init?.body)).publicKey).serialize()); + }; + client.sendRawTransaction = async (raw) => { sends++; return bs58.encode(VersionedTransaction.deserialize(Uint8Array.from(raw)).signatures[0]!); }; + const rule: AutoRule = { + id: 'disable-during-build', mint: request().mint, kind: 'limit_buy', triggerPct: -10, + triggerPriceSol: 1, buySol: 0.01, sellPercent: 0, enabled: true, createdAt: 1, + }; + db.addRule(rule); const services = fakeServices(); + services.selectWallets = () => [w]; services.allWallets = () => [w]; + services.getMintBalances = async () => new Map(); services.batchPumpTrade = batchPumpTrade; + const firing = fire(rule, 1, async () => {}, services); await entered.promise; + db.updateRule(rule.id, { enabled: false }); release.resolve(); await firing; + assert.equal(sends, 0); assert.equal(rule.enabled, false); assert.equal(rule.failedAttempts, undefined); + check('disabling a rule during its builder request revokes authorization before submission'); + } + + { + const id = stageConfirmation(2, 'old settings', async () => {}); + db.updateSettings({ slippagePercent: 80 }); assert.equal(takeConfirmation(2, id), undefined); + const changedWallet = stageConfirmation(2, 'old wallets', async () => {}); + wallets.generateSolanaWallet('selection-changed'); assert.equal(takeConfirmation(2, changedWallet), undefined); + check('confirmation parameters are invalidated when settings or wallet selection change'); + } + + { + const source = wallets.generateSolanaWallet('fund-main'); wallets.setMain(source.id); + const recipient = wallets.generateSolanaWallet('fund-recipient'); wallets.addToGroup(recipient.id, 'fund-test'); + db.updateSettings({ activeGroup: 'fund-test' }); + let targetLamports = 100_000_000; let transfers: bigint[] = []; + mockRpc(() => targetLamports); client.getBalance = async () => 1e9; + client.getLatestBlockhash = async () => ({ blockhash: Keypair.generate().publicKey.toBase58(), lastValidBlockHeight: 1 }); + client.sendRawTransaction = async (raw) => { + const tx = VersionedTransaction.deserialize(Uint8Array.from(raw)); + transfers.push(...tx.message.compiledInstructions.filter((ix) => + tx.message.staticAccountKeys[ix.programIdIndex]?.equals(SystemProgram.programId)).map((ix) => Buffer.from(ix.data).readBigUInt64LE(4))); + return bs58.encode(tx.signatures[0]!); + }; + await promptFund(ctx(2), 'topup', 0.2); let id = [...session(2).confirmations.keys()][0]!; + targetLamports = 150_000_000; await takeConfirmation(2, id)!.run(ctx(2)); + assert.deepEqual(transfers, [50_000_000n]); + await promptFund(ctx(2), 'topup', 0.2); id = [...session(2).confirmations.keys()][0]!; + targetLamports = 0; await takeConfirmation(2, id)!.run(ctx(2)); assert.equal(transfers.length, 1); + check('top-ups shrink changed deficits and refuse increases above the confirmation'); + } + + { + const w = wallets.generateSolanaWallet('remove-in-flight'); mockRpc(() => 1e9); + const entered = deferred(); const release = deferred(); let sends = 0; + globalThis.fetch = async (_url, init) => { + entered.resolve(); await release.promise; + return new Response(builtTrade(JSON.parse(String(init?.body)).publicKey).serialize()); + }; + client.sendRawTransaction = async (raw) => { sends++; return bs58.encode(VersionedTransaction.deserialize(Uint8Array.from(raw)).signatures[0]!); }; + const trade = batchPumpTrade([w], request()); await entered.promise; + await promptRemove(ctx(1), w.id); const id = [...session(1).confirmations.keys()][0]!; + const removing = takeConfirmation(1, id)!.run(ctx(1)); + await Promise.resolve(); assert.ok(wallets.walletById(w.id)); + release.resolve(); await Promise.all([trade, removing]); + assert.equal(sends, 0); assert.equal(wallets.walletById(w.id), undefined); + check('wallet removal cancels pre-send builds and drains active work before erasing the key'); + } + + { + db.updateSettings({ activeGroup: null }); + const w = wallets.generateSolanaWallet('reset-in-flight'); mockRpc(() => 1e9); + const entered = deferred(); const release = deferred(); let sends = 0; + globalThis.fetch = async (_url, init) => { + entered.resolve(); await release.promise; + return new Response(builtTrade(JSON.parse(String(init?.body)).publicKey).serialize()); + }; + client.sendRawTransaction = async (raw) => { sends++; return bs58.encode(VersionedTransaction.deserialize(Uint8Array.from(raw)).signatures[0]!); }; + stageConfirmation(2, 'old account', async () => {}); setPending(2, { kind: 'factory_reset' }); + const first = batchPumpTrade([w], request()); await entered.promise; + const queued = withExecution(async () => { throw new Error('obsolete queued action ran'); }); + const rejected = assert.rejects(queued, ExecutionCancelledError); + const reset = executeFactoryReset(ctx(1), RESET_PHRASE); + await Promise.resolve(); assert.ok(db.wallets().length > 0, 'reset waits until the active operation finishes'); + release.resolve(); const result = await first; await Promise.all([reset, rejected]); + assert.equal(sends, 0); assert.equal(result.results[0]!.confirmationUnknown, undefined); + assert.match(result.results[0]!.error ?? '', /account changed/); + assert.equal(db.wallets().length, 0); assert.equal(db.tradeLog().length, 0); + assert.equal(session(2).confirmations.size, 0); assert.equal(session(2).pending, undefined); + check('reset drains bookkeeping, cancels pending submissions and clears every owner session'); + } + + { + await assert.rejects(withExecution(async () => withExecutionMaintenance(async () => {})), ExecutionCancelledError); + check('maintenance inside an active operation rejects instead of deadlocking'); + } + + console.log(`\n${passed} concurrency regressions passed.`); +} finally { + globalThis.fetch = originalFetch; vault.lockVault(); fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/copyevents-regressions.ts b/scripts/copyevents-regressions.ts new file mode 100644 index 0000000..4a7406b --- /dev/null +++ b/scripts/copyevents-regressions.ts @@ -0,0 +1,330 @@ +/** Offline copy receipt regressions. RPC methods and execution are substituted. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { CopyTarget } from '../src/store/db.js'; +import type { CopyBuyServices } from '../src/services/copytrade.js'; +import type { WalletRecord } from '../src/types.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-copyevents-')); +process.env.BOT_TOKEN = '123:OFFLINE_TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; +process.env.SOLANA_RPC_URL = 'http://127.0.0.1:1'; +process.env.VAULT_AUTOLOCK_MINUTES = '0'; + +const { db } = await import('../src/store/db.js'); +const { rpc } = await import('../src/chains/solana.js'); +const { pollCopyTargets, syncSubscriptions, stopSubscriptions, resetProcessed, mirrorBuy, detectTokenMoves } = + await import('../src/services/copytrade.js'); +const { withExecutionMaintenance, ExecutionCancelledError } = await import('../src/services/execution.js'); + +type LogCallback = (logs: { signature: string; err: null }) => void; +type Signature = { signature: string; err: null | { InstructionError: [number, string] } }; +const connection = rpc() as unknown as { + getSignaturesForAddress(address: { toBase58(): string }, options: { limit: number; before?: string }): Promise; + getParsedTransactions(signatures: string[]): Promise | null>>; + onLogs(address: { toBase58(): string }, callback: LogCallback): number; + removeOnLogsListener(id: number): Promise; +}; +const addressA = '11111111111111111111111111111111'; +const addressB = 'So11111111111111111111111111111111111111112'; +const noopNotify = async (_text: string) => {}; +const sig = (signature: string): Signature => ({ signature, err: null }); +const callbacks = new Map(); +let subscriptionId = 0; +connection.onLogs = (address, callback) => { + callbacks.set(address.toBase58(), callback); + return ++subscriptionId; +}; +connection.removeOnLogsListener = async () => {}; + +function receipt(owners: string[] = [], failed = false) { + return { + meta: { + err: failed ? { InstructionError: [0, 'Custom'] } : null, + preTokenBalances: [], + postTokenBalances: owners.map((owner, i) => ({ mint: `offline-mint-${i}`, owner, uiTokenAmount: { uiAmount: 1 } })), + preBalances: owners.map(() => 100), + postBalances: owners.map(() => 100), + }, + transaction: { message: { accountKeys: owners.map((pubkey) => ({ pubkey })) } }, + }; +} + +function target(id: string, address = addressA, lastSignature: string | undefined = 'old'): CopyTarget { + const value: CopyTarget = { + id, address, label: id, buySol: 0.05, sizeMode: 'fixed', sizePercent: 5, + entryMode: 'first', maxEntries: 1, exitMode: 'off', copiedMints: [], enabled: true, + createdAt: 1, lastSignature, + }; + db.addCopyTarget(value); + return value; +} + +async function clean(): Promise { + await stopSubscriptions(); + for (const t of [...db.copyTargets()]) db.removeCopyTarget(t.id); + callbacks.clear(); + resetProcessed(); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let i = 0; i < 100; i++) { + if (predicate()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + assert.ok(predicate(), 'offline callback did not settle'); +} + +let passed = 0; +const check = (name: string) => { passed++; console.log(` ✓ ${name}`); }; + +try { + { + const balance = (amount: string, uiAmount: number | null) => ({ + mint: 'raw-mint', owner: addressA, uiTokenAmount: { amount, decimals: 6, uiAmount }, + }); + assert.deepEqual(detectTokenMoves([balance('1000000', null)], [balance('2500000', null)], addressA), + [{ mint: 'raw-mint', delta: 1.5, before: 1 }]); + assert.deepEqual(detectTokenMoves([balance('1000000', 1)], [balance('1000000', 50)], addressA), []); + assert.deepEqual(detectTokenMoves([balance('unreadable', null)], [balance('1000000', 1)], addressA), []); + check('raw token amounts survive null UI values and display scaling cannot fabricate a move'); + } + + { + const value = target('null-receipt'); + connection.getSignaturesForAddress = async () => [sig('new'), sig('old')]; + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [null]; }; + await pollCopyTargets(noopNotify); + assert.equal(reads, 2); + assert.equal(value.lastSignature, 'old'); + assert.equal(value.handledSignatures, undefined); + connection.getParsedTransactions = async () => { reads++; return [receipt()]; }; + await pollCopyTargets(noopNotify); + assert.equal(reads, 3); + assert.equal(value.lastSignature, 'new'); + assert.deepEqual(value.handledSignatures, ['new']); + check('null receipts remain unclaimed and reconcile after RPC recovery'); + await clean(); + } + + { + const value = target('throw-receipt'); + connection.getSignaturesForAddress = async () => [sig('newest'), sig('middle'), sig('old')]; + const readOrder: string[] = []; + connection.getParsedTransactions = async ([signature]) => { + readOrder.push(signature!); + if (signature === 'middle') throw new Error('Offline RPC unavailable'); + return [receipt()]; + }; + await pollCopyTargets(noopNotify); + assert.equal(value.lastSignature, 'old'); + assert.deepEqual(readOrder, ['middle', 'middle']); + connection.getParsedTransactions = async ([signature]) => { readOrder.push(signature!); return [receipt()]; }; + await pollCopyTargets(noopNotify); + assert.deepEqual(readOrder.slice(2), ['middle', 'newest']); + assert.equal(value.lastSignature, 'newest'); + check('an unreadable oldest receipt blocks the cursor and newer backlog'); + await clean(); + } + + { + const value = target('partial-cursor'); + connection.getSignaturesForAddress = async () => [sig('newest'), sig('middle'), sig('first'), sig('old')]; + connection.getParsedTransactions = async ([signature]) => [signature === 'middle' ? null : receipt()]; + await pollCopyTargets(noopNotify); + assert.equal(value.lastSignature, 'first'); + assert.deepEqual(value.handledSignatures, ['first']); + check('a successful earlier receipt advances the cursor only to that receipt'); + await clean(); + } + + { + target('shared-a', addressA); + target('shared-b', addressB); + connection.getSignaturesForAddress = async () => [sig('shared'), sig('old')]; + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [receipt([addressA, addressB])]; }; + const before = db.copyDecisions(100).length; + await pollCopyTargets(noopNotify); + assert.equal(reads, 2); + assert.equal(db.copyDecisions(100).length, before + 2); + assert.ok(db.copyTargets().every((t) => t.handledSignatures?.includes('shared'))); + check('one atomic signature is interpreted separately for every followed target'); + await clean(); + } + + { + const value = target('socket-restart'); + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [receipt()]; }; + connection.getSignaturesForAddress = async () => [sig('socket-event'), sig('old')]; + await syncSubscriptions(noopNotify); + callbacks.get(addressA)!({ signature: 'socket-event', err: null }); + await waitFor(() => !!value.handledSignatures?.includes('socket-event')); + assert.equal(value.lastSignature, 'old'); + const saved = JSON.parse(fs.readFileSync(path.join(dataDir, 'wallets.json'), 'utf8')); + assert.deepEqual(saved.copyTargets[0].handledSignatures, ['socket-event']); + resetProcessed(); + await pollCopyTargets(noopNotify); + assert.equal(reads, 1); + assert.equal(value.lastSignature, 'socket-event'); + check('socket receipts are durable and a fresh process does not replay them'); + await clean(); + } + + { + const value = target('overlap'); + let release!: (value: ReturnType) => void; + const pending = new Promise>((resolve) => { release = resolve; }); + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [await pending]; }; + connection.getSignaturesForAddress = async () => [sig('overlap-event'), sig('old')]; + await syncSubscriptions(noopNotify); + callbacks.get(addressA)!({ signature: 'overlap-event', err: null }); + await waitFor(() => reads === 1); + const poll = pollCopyTargets(noopNotify); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(value.lastSignature, 'old'); + release(receipt()); + await poll; + assert.equal(reads, 1); + assert.equal(value.lastSignature, 'overlap-event'); + check('socket and poll share a pending read and commit one receipt'); + await clean(); + } + + { + const value = target('pagination'); + const signatures = Array.from({ length: 105 }, (_, i) => sig(`page-${104 - i}`)); + const pageRequests: Array = []; + connection.getSignaturesForAddress = async (_address, options) => { + pageRequests.push(options.before); + if (!options.before) return signatures.slice(0, options.limit); + return [...signatures.slice(100), sig('old')]; + }; + const readOrder: string[] = []; + connection.getParsedTransactions = async ([signature]) => { readOrder.push(signature!); return [receipt()]; }; + await pollCopyTargets(noopNotify); + assert.deepEqual(pageRequests, [undefined, 'page-5']); + assert.equal(readOrder.length, 105); + assert.equal(readOrder[0], 'page-0'); + assert.equal(readOrder.at(-1), 'page-104'); + assert.equal(value.lastSignature, 'page-104'); + check('reconciliation paginates beyond ten signatures and follows oldest first'); + await clean(); + } + + { + const value = target('bounded-gap'); + let pages = 0; + connection.getSignaturesForAddress = async (_address, options) => { + pages++; + return Array.from({ length: options.limit }, (_, i) => sig(`gap-${pages}-${i}`)); + }; + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [receipt()]; }; + const notices: string[] = []; + await pollCopyTargets(async (text) => { notices.push(text); }); + assert.equal(pages, 5); + assert.equal(reads, 0); + assert.equal(value.enabled, false); + assert.equal(value.lastSignature, 'old'); + assert.ok(notices.some((text) => text.includes('gap in the history'))); + check('an unknown history gap pauses the target without replaying a partial backlog'); + await clean(); + } + + { + const value = target('failed-onchain'); + connection.getSignaturesForAddress = async () => [ + { signature: 'listed-failure', err: { InstructionError: [0, 'Custom'] } }, sig('parsed-failure'), sig('old'), + ]; + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [receipt([addressA], true)]; }; + const decisions = db.copyDecisions(100).length; + await pollCopyTargets(noopNotify); + assert.equal(reads, 1); + assert.equal(value.lastSignature, 'listed-failure'); + assert.equal(db.copyDecisions(100).length, decisions); + assert.deepEqual(value.handledSignatures, ['parsed-failure', 'listed-failure']); + check('failed transactions advance checkpoints without triggering token moves'); + await clean(); + } + + const wallet: WalletRecord = { + id: 'offline-wallet', kind: 'solana', address: 'offline-address', label: 'Offline wallet', + secret: '', groups: [], isMain: false, disabled: false, createdAt: 1, + }; + for (const cancellation of ['disable', 'remove'] as const) { + const value = target(`cancel-${cancellation}`); + let release!: (value: Awaited>) => void; + const screen = new Promise>>((resolve) => { release = resolve; }); + let trades = 0; + const services: CopyBuyServices = { + selectWallets: () => [wallet], getMintBalances: async () => new Map(), screenToken: () => screen, + batchPumpTrade: async () => { trades++; return { results: [], succeeded: 0, failed: 0, startedAt: 1, finishedAt: 2 }; }, + measureTokensGained: async () => 0, + }; + const work = mirrorBuy(value, { mint: 'cancel-mint', delta: 1, before: 0 }, 1, noopNotify, services); + const rejected = assert.rejects(work, ExecutionCancelledError); + await waitFor(() => !!value.entryCounts?.['cancel-mint']); + if (cancellation === 'disable') db.updateCopyTarget(value.id, { enabled: false }); + else db.removeCopyTarget(value.id); + release({ verdict: { safe: true, reasons: [], notes: [] } }); + await rejected; + assert.equal(trades, 0); + check(`${cancellation} while screening prevents a copied buy submission`); + await clean(); + } + + { + const value = target('reset-pending-read'); + let release!: (value: ReturnType) => void; + const pending = new Promise>((resolve) => { release = resolve; }); + let reads = 0; + connection.getSignaturesForAddress = async () => [sig('pre-reset'), sig('old')]; + connection.getParsedTransactions = async () => { reads++; return [await pending]; }; + const work = pollCopyTargets(noopNotify); + await waitFor(() => reads === 1); + await withExecutionMaintenance(async () => {}); + release(receipt()); + await work; + assert.equal(value.lastSignature, 'old'); + assert.equal(value.handledSignatures, undefined); + check('maintenance invalidates a pending receipt before it can commit or spend'); + await clean(); + } + + { + const value = target('flood'); + let release!: (value: ReturnType) => void; + const pending = new Promise>((resolve) => { release = resolve; }); + let reads = 0; + connection.getParsedTransactions = async () => { reads++; return [await pending]; }; + await syncSubscriptions(noopNotify); + const callback = callbacks.get(addressA)!; + callback({ signature: 'active-flood', err: null }); + await waitFor(() => reads === 1); + for (let i = 0; i < 85; i++) callback({ signature: `queued-flood-${i}`, err: null }); + assert.equal(value.enabled, false); + const subscriptionsBefore = subscriptionId; + await syncSubscriptions(noopNotify); + assert.equal(subscriptionId, subscriptionsBefore); + release(receipt()); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(reads, 1, 'the disabled target backlog must be removed'); + assert.equal(value.handledSignatures, undefined); + check('flood protection persists disablement and drops the target backlog'); + await clean(); + } + + console.log(`\nCopy event regressions: ${passed} passed.`); +} finally { + await stopSubscriptions(); + fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/deep-transaction-regressions.ts b/scripts/deep-transaction-regressions.ts new file mode 100644 index 0000000..8ff4f52 --- /dev/null +++ b/scripts/deep-transaction-regressions.ts @@ -0,0 +1,232 @@ +/** Adversarial external-builder checks. All HTTP and send operations are offline. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createHash, createPublicKey, verify } from 'node:crypto'; +import bs58 from 'bs58'; +import { ComputeBudgetProgram, Keypair, PublicKey, SystemProgram, TransactionInstruction, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { createAssociatedTokenAccountIdempotentInstruction, createCloseAccountInstruction, createSyncNativeInstruction, createApproveInstruction, createSetAuthorityInstruction, AuthorityType, getAssociatedTokenAddressSync, NATIVE_MINT } from '@solana/spl-token'; +import type { TradeArgs } from '../src/trade/pumpportal.js'; +import type { JupQuote } from '../src/trade/jupiter.js'; + +process.env.BOT_TOKEN = '123:TEST'; +process.env.OWNER_IDS = '1'; +process.env.SOLANA_RPC_URL = 'http://127.0.0.1:8899'; +process.env.SOLANA_SEND_RPC_URL = process.env.SOLANA_RPC_URL; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; + +const { buildTrade, buildTradeBundle, signTx } = await import('../src/trade/pumpportal.js'); +const { getQuote, buildSwap, executeSwap, signSwap, validateQuote } = await import('../src/trade/jupiter.js'); +const { assertExternalTrade, ExternalTransactionValidationError, JITO_TIP_ACCOUNTS, JUPITER_PROGRAM, pumpSwapVariants } = await import('../src/trade/validation.js'); +const { rpc, WSOL_MINT } = await import('../src/chains/solana.js'); +const { endpoints } = await import('../src/config.js'); +const originalFetch = globalThis.fetch; +const client = rpc(); +const originalSend = client.sendRawTransaction; +let sends = 0; +client.sendRawTransaction = async () => { sends++; throw new Error('No broadcast is permitted in this regression.'); }; +globalThis.fetch = async () => { throw new Error('No network request is permitted in this regression.'); }; +const wallet = Keypair.generate(); +const other = Keypair.generate(); +const mint = Keypair.generate().publicKey; +const ata = getAssociatedTokenAddressSync(NATIVE_MINT, wallet.publicKey); +const anchor = (name: string) => createHash('sha256').update(`global:${name}`).digest().subarray(0, 8); +const pumpInstruction = (action = 'buy') => new TransactionInstruction({ + programId: new PublicKey('6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P'), + keys: [{ pubkey: wallet.publicKey, isSigner: true, isWritable: true }, { pubkey: mint, isSigner: false, isWritable: false }], + data: Buffer.concat([anchor(action), Buffer.alloc(16, 1)]), +}); +const jupiterInstruction = () => new TransactionInstruction({ + programId: new PublicKey(JUPITER_PROGRAM), + keys: [{ pubkey: wallet.publicKey, isSigner: true, isWritable: true }, { pubkey: mint, isSigner: false, isWritable: false }], + data: Buffer.concat([anchor('route'), Buffer.alloc(4)]), +}); +function transaction(instructions: TransactionInstruction[], payer = wallet.publicKey): VersionedTransaction { + return new VersionedTransaction(new TransactionMessage({ payerKey: payer, recentBlockhash: other.publicKey.toBase58(), instructions }).compileToV0Message()); +} +const pumpArgs = { + publicKey: wallet.publicKey.toBase58(), action: 'buy' as const, mint: mint.toBase58(), + amount: 0.001, denominatedInSol: 'true' as const, slippage: 5, priorityFee: 0.00005, pool: 'pump' as const, +}; +const quoteParams = { inputMint: WSOL_MINT, outputMint: mint.toBase58(), amount: 1_000_000n, slippageBps: 50 }; +const quote = { + inputMint: WSOL_MINT, outputMint: mint.toBase58(), inAmount: '1000000', outAmount: '10000', + otherAmountThreshold: '9950', swapMode: 'ExactIn' as const, slippageBps: 50, priceImpactPct: '0.01', + routePlan: [{ swapInfo: { label: 'Offline fixture' }, percent: 100 }], platformFee: null, +}; +const policy = { + wallet: wallet.publicKey.toBase58(), priorityFeeSol: 0.00005, swaps: pumpSwapVariants('pump', 'buy'), + mints: [WSOL_MINT, mint.toBase58()], wrappedSolLamports: 1_000_000n, +}; +let passed = 0; +function ok(name: string): void { passed++; console.log(` ✓ ${name}`); } +function refuses(tx: VersionedTransaction, name: string, extra: Partial = {}): void { + assert.throws(() => assertExternalTrade(tx, { ...policy, ...extra }), ExternalTransactionValidationError); + ok(name); +} +function bytesResponse(tx: VersionedTransaction): Response { return new Response(tx.serialize()); } +function validSignature(tx: VersionedTransaction): boolean { + const pk = createPublicKey({ format: 'der', type: 'spki', key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), wallet.publicKey.toBuffer()]) }); + return verify(null, tx.message.serialize(), pk, tx.signatures[0]!); +} + +try { + let quoteRequests = 0; + let swapRequests = 0; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.startsWith(`${endpoints.jupiterQuote}?`)) { + quoteRequests++; + assert.equal(new URL(url).searchParams.get('swapMode'), 'ExactIn'); + return new Response(JSON.stringify({ ...quote, inputMint: other.publicKey.toBase58() })); + } + swapRequests++; + throw new Error('A rejected quote must not request a transaction.'); + }; + await assert.rejects(executeSwap(wallet, { ...quoteParams, priorityFeeSol: 0.00005 }), ExternalTransactionValidationError); + assert.equal(quoteRequests, 1); + assert.equal(swapRequests, 0); + assert.equal(sends, 0); + ok('a mismatched quote never reaches transaction building or sending'); + + for (const [name, patch] of [ + ['wrong output mint', { outputMint: other.publicKey.toBase58() }], + ['wrong input amount', { inAmount: '10000000000' }], + ['ExactOut response', { swapMode: 'ExactOut' }], + ['changed slippage', { slippageBps: 9999 }], + ['zero output', { outAmount: '0' }], + ['zero threshold', { otherAmountThreshold: '0' }], + ['loosened threshold', { otherAmountThreshold: '9000' }], + ['threshold above quote', { otherAmountThreshold: '10001' }], + ['noncanonical amount', { inAmount: '01000000' }], + ['u64 overflow', { outAmount: '18446744073709551616' }], + ['missing route', { routePlan: [] }], + ['unexpected fee', { platformFee: { amount: '1', feeBps: 1 } }], + ] as const) { + assert.throws(() => validateQuote({ ...quote, ...patch }, quoteParams), ExternalTransactionValidationError); + ok(`quote rejects ${name}`); + } + assert.equal(validateQuote(quote, quoteParams), quote); + assert.equal(validateQuote({ ...quote, otherAmountThreshold: '9951' }, quoteParams).otherAmountThreshold, '9951'); + ok('valid exact-input quote and conservative threshold remain compatible'); + + const bareTransfer = transaction([SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: other.publicKey, lamports: 10_000_000_000 })]); + globalThis.fetch = async () => bytesResponse(bareTransfer); + await assert.rejects(buildTrade(pumpArgs), ExternalTransactionValidationError); + ok('PumpPortal cannot return a plain unrelated SOL transfer'); + refuses(transaction([pumpInstruction(), SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: other.publicKey, lamports: 10_000_000_000 })]), 'appended 10 SOL wallet transfer is rejected'); + refuses(transaction([pumpInstruction(), SystemProgram.assign({ accountPubkey: wallet.publicKey, programId: other.publicKey })]), 'wallet assignment/nonce-style System operations are rejected'); + refuses(transaction([pumpInstruction(), new TransactionInstruction({ programId: other.publicKey, keys: [{ pubkey: wallet.publicKey, isSigner: true, isWritable: true }], data: Buffer.alloc(8) })]), 'an unknown program cannot be appended beside a valid swap'); + refuses(transaction([pumpInstruction(), createApproveInstruction(ata, other.publicKey, wallet.publicKey, 1n)]), 'token approval is rejected'); + refuses(transaction([pumpInstruction(), createSetAuthorityInstruction(ata, wallet.publicKey, AuthorityType.AccountOwner, other.publicKey)]), 'token authority replacement is rejected'); + refuses(transaction([pumpInstruction(), createCloseAccountInstruction(ata, other.publicKey, wallet.publicKey)]), 'token closure to an unexpected recipient is rejected'); + + const wrapped = transaction([ + createAssociatedTokenAccountIdempotentInstruction(wallet.publicKey, ata, wallet.publicKey, NATIVE_MINT), + SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: ata, lamports: 1_000_000 }), + createSyncNativeInstruction(ata), pumpInstruction(), createCloseAccountInstruction(ata, wallet.publicKey, wallet.publicKey), + ]); + assertExternalTrade(wrapped, policy); + ok('bounded WSOL ATA funding and wallet cleanup remain compatible'); + refuses(transaction([pumpInstruction(), SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: ata, lamports: 1_000_001 })]), 'WSOL funding cannot exceed the requested input'); + refuses(transaction([pumpInstruction(), ...[600_000, 600_000].map((lamports) => SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: ata, lamports }))]), 'split WSOL transfers cannot bypass the aggregate input cap'); + const foreignAta = getAssociatedTokenAddressSync(NATIVE_MINT, other.publicKey); + refuses(transaction([pumpInstruction(), createAssociatedTokenAccountIdempotentInstruction(wallet.publicKey, foreignAta, other.publicKey, NATIVE_MINT)]), 'wallet cannot pay for an unrelated owner ATA'); + + refuses(transaction([pumpInstruction()], other.publicKey), 'a foreign fee payer/additional signer is rejected'); + const readonly = transaction([pumpInstruction()]); + readonly.message.header.numReadonlySignedAccounts = 1; + refuses(readonly, 'the wallet signer must be writable'); + const missingSignature = transaction([pumpInstruction()]); + missingSignature.signatures = []; + refuses(missingSignature, 'malformed signature count is rejected'); + const loadedProgram = transaction([pumpInstruction()]); + if (loadedProgram.message.version === 0) { + loadedProgram.message.addressTableLookups.push({ accountKey: other.publicKey, writableIndexes: [], readonlyIndexes: [0] }); + loadedProgram.message.compiledInstructions[0]!.programIdIndex = loadedProgram.message.staticAccountKeys.length; + } + refuses(loadedProgram, 'an unresolved lookup-table program cannot hide a compute fee'); + + const withFee = (units: number, price: bigint) => transaction([ComputeBudgetProgram.setComputeUnitLimit({ units }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: price }), pumpInstruction()]); + assertExternalTrade(withFee(200_000, 250_000n), policy); + ok('exact requested 50000-lamport compute fee is accepted'); + refuses(withFee(1_400_000, 1_000_000_000n), 'API cannot replace a small fee with a 1.4 SOL priority fee'); + refuses(withFee(1, 1n), 'fractional-lamport fee rounds up rather than disappearing', { priorityFeeSol: 0 }); + refuses(withFee(200_000, 18_446_744_073_709_551_615n), 'u64 CU price is checked with bigint precision'); + refuses(transaction([ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 100_000n }), pumpInstruction()]), 'missing CU limit uses a conservative 1.4m-unit fee ceiling'); + refuses(transaction([ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 0 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 0 }), pumpInstruction()]), 'duplicate compute-price instructions are rejected'); + refuses(transaction([new TransactionInstruction({ programId: ComputeBudgetProgram.programId, keys: [], data: Buffer.alloc(9) }), pumpInstruction()]), 'deprecated compute-budget instructions are rejected'); + refuses(withFee(1_400_001, 0n), 'out-of-range compute limit is rejected'); + + globalThis.fetch = async () => bytesResponse(transaction([pumpInstruction()])); + const built = await buildTrade(pumpArgs); + built.signatures[0] = new Uint8Array(64).fill(42); + const signed = signTx(built, wallet); + assert.ok(validSignature(signed)); + assert.notEqual(signed, built); + assert.deepEqual(built.signatures[0], new Uint8Array(64).fill(42)); + ok('signing replaces builder signatures and leaves the external object untouched'); + built.message = transaction([pumpInstruction(), SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: other.publicKey, lamports: 1 })]).message; + assert.throws(() => signTx(built, wallet), ExternalTransactionValidationError); + assert.throws(() => signTx(bareTransfer, wallet), ExternalTransactionValidationError); + ok('signing rechecks modified messages and refuses unvalidated messages'); + + const tip = new PublicKey([...JITO_TIP_ACCOUNTS][0]!); + const tipTx = transaction([pumpInstruction(), SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: tip, lamports: 100_000 })]); + globalThis.fetch = async () => new Response(JSON.stringify([bs58.encode(tipTx.serialize())])); + const bundled = await buildTradeBundle([{ ...pumpArgs, priorityFee: 0.0001 }]); + assert.ok(validSignature(signTx(bundled[0]!, wallet))); + ok('documented first-transaction Jito tip remains compatible'); + const doubleBid = transaction([ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 500_000n }), pumpInstruction(), + SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: tip, lamports: 100_000 })]); + globalThis.fetch = async () => new Response(JSON.stringify([bs58.encode(doubleBid.serialize())])); + await assert.rejects(buildTradeBundle([{ ...pumpArgs, priorityFee: 0.0001 }]), /combined compute fee/); + ok('one bundle fee parameter cannot fund both a full compute fee and a full tip'); + globalThis.fetch = async () => new Response(JSON.stringify([bs58.encode(tipTx.serialize()), bs58.encode(tipTx.serialize())])); + await assert.rejects(buildTradeBundle([{ ...pumpArgs, priorityFee: 0.0001 }, { ...pumpArgs, priorityFee: 0 }]), ExternalTransactionValidationError); + ok('later bundle transaction cannot add another tip'); + globalThis.fetch = async () => new Response(JSON.stringify([])); + await assert.rejects(buildTradeBundle([pumpArgs]), /transactions for/); + ok('builder bundle count is checked before signing'); + + globalThis.fetch = async () => new Response(JSON.stringify({ swapTransaction: Buffer.from(transaction([jupiterInstruction()]).serialize()).toString('base64') })); + const swap = await buildSwap(quote, wallet.publicKey.toBase58(), 0.00005); + swap.signatures[0] = new Uint8Array(64).fill(42); + assert.ok(validSignature(signSwap(swap, wallet))); + assert.notEqual(signSwap(swap, wallet), swap); + ok('Jupiter also discards supplied signatures and signs a fresh object'); + globalThis.fetch = async () => new Response(JSON.stringify({ swapTransaction: Buffer.from(bareTransfer.serialize()).toString('base64') })); + await assert.rejects(buildSwap(quote, wallet.publicKey.toBase58(), 0.00005), ExternalTransactionValidationError); + ok('Jupiter cannot return an unrelated standalone transaction'); + globalThis.fetch = async () => new Response(JSON.stringify({ ...quote, otherAmountThreshold: '0' })); + await assert.rejects(getQuote(quoteParams), ExternalTransactionValidationError); + assert.equal(sends, 0); + ok('all adversarial cases finish with zero broadcasts'); + + // Replay actual unsigned API messages captured with throwaway public keys. + // This verifies envelope compatibility, not on-chain validity or spend intent. + const fixtures = JSON.parse(fs.readFileSync(new URL('./fixtures/external-builder-unsigned.json', import.meta.url), 'utf8')) as { + samples: Array<{ kind: 'pump' | 'pump-bundle' | 'jupiter'; transactionBase64: string; args?: TradeArgs; quote?: JupQuote; userPublicKey?: string; priorityFeeSol?: number; index?: number }>; + }; + for (const sample of fixtures.samples) { + const raw = Buffer.from(sample.transactionBase64, 'base64'); + const unsigned = VersionedTransaction.deserialize(raw); + assert.equal(unsigned.signatures.some((signature) => signature.some((byte) => byte !== 0)), false); + if (sample.kind === 'pump') { + globalThis.fetch = async () => new Response(raw); + await buildTrade(sample.args!); + } else if (sample.kind === 'pump-bundle') { + globalThis.fetch = async () => new Response(JSON.stringify([bs58.encode(raw)])); + await buildTradeBundle([sample.args!]); + } else { + globalThis.fetch = async () => new Response(JSON.stringify({ swapTransaction: sample.transactionBase64 })); + await buildSwap(sample.quote!, sample.userPublicKey!, sample.priorityFeeSol!); + } + ok(`captured unsigned ${sample.kind} ${sample.args?.action ?? 'SOL→USDC'}${sample.index === undefined ? '' : ` index ${sample.index}`} remains compatible`); + } + assert.equal(sends, 0); + console.log(`\n${passed} external-transaction regressions passed.`); +} finally { + globalThis.fetch = originalFetch; + client.sendRawTransaction = originalSend; +} diff --git a/scripts/fixtures/external-builder-unsigned.json b/scripts/fixtures/external-builder-unsigned.json new file mode 100644 index 0000000..63b207e --- /dev/null +++ b/scripts/fixtures/external-builder-unsigned.json @@ -0,0 +1,112 @@ +{ + "capturedAt": "2026-10-02T19:01:37.360Z", + "source": "Read-only calls to PumpPortal trade-local and Jupiter quote/swap; throwaway public keys. No signing, simulation or broadcasting.", + "samples": [ + { + "kind": "pump", + "args": { + "publicKey": "4rGncwEhSW6AMgqCMhfRK5pBfUaoESHjgmpwCAGNsYDE", + "action": "buy", + "mint": "2xHkesAQteG9yz48SDaVAtKdFU6Bvdo9sXS3uQCbpump", + "amount": 0.01, + "denominatedInSol": "true", + "slippage": 15, + "priorityFee": 0.00005, + "pool": "pump" + }, + "transactionBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAECzkyXs20dM2amjl+lRw5hmioQC4SfUY/fv4HZt/yLnxhEnvqWvbYD4b44ZlhWWa6ixQE7391Frx1x3IM0yLDXY/rb6PysvNWxjmHB1zP/8RGOhR6ET+OmkOhiqp1dAQNjcc8oHpIk4+2Ri9T0eJs6nNn95Ke+N4rHSCBsAkwmpQbTTVNjwijNdYevGuP4u3CDnyIBvUj1FxxYl6KOjudD/QPFGbS2IKEqsGixmG8rrXa1wzu8prf/DjwGpT2WrSJnSS9k+E8EKOiKyyeyqtnlZPmIqtVu4QZEQeok4D+XAtTAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WR0Fkt1hjxUe83xcBScLDkhYJpbDingwrdqqgeUPNfKP0nty+/Z83CEGFBr590oPCdka7n3mOvjmGdoFX9cToRmdAka9SvYx3vtboZQtbiKD5PGQo28GJ128XBwyGqFp9AQHAAUCEJgCAAcACQPlfAQAAAAAAAgGAAEACQ4PAQEKExALCQIDAQAODwQREhMFFBUGDA0aZjI9EgHa6+pUi3tgTAAAABc7sQAAAAAAMgABaPO83kHcfHKmDOzNhB/dL7SSKVPBqjKzDKSprV71i0MDFh8gCAEICwUCGB0c" + }, + { + "kind": "pump", + "args": { + "publicKey": "4rGncwEhSW6AMgqCMhfRK5pBfUaoESHjgmpwCAGNsYDE", + "action": "sell", + "mint": "2xHkesAQteG9yz48SDaVAtKdFU6Bvdo9sXS3uQCbpump", + "amount": 1000, + "denominatedInSol": "false", + "slippage": 15, + "priorityFee": 0.00005, + "pool": "pump" + }, + "transactionBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAECjkyXs20dM2amjl+lRw5hmioQC4SfUY/fv4HZt/yLnxhEnvqWvbYD4b44ZlhWWa6ixQE7391Frx1x3IM0yLDXY/rb6PysvNWxjmHB1zP/8RGOhR6ET+OmkOhiqp1dAQNjcc8oHpIk4+2Ri9T0eJs6nNn95Ke+N4rHSCBsAkwmpQbTTVNjwijNdYevGuP4u3CDnyIBvUj1FxxYl6KOjudD/QkvZPhPBCjoissnsqrZ5WT5iKrVbuEGREHqJOA/lwLUwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkdBZLdYY8VHvN8XAUnCw5IWCaWw4p4MK3aqoHlDzXyj9J7cvv2fNwhBhQa+fdKDwnZGu595jr45hnaBV/XE6EZtD3+JmEGzOCNPfnxIzlG0ySsrCfJR1hPjdm5KWSuInAEBgAFAhCYAgAGAAkD5XwEAAAAAAAHBgABAAgQCgEBCRIRCwgCAwEAEAQKEhMUDA0FDg8iMzKFpAF/g60Aypo7AAAAAAAAAAAAAAAAMgAAAAAAAADwvwFo87zeQdx8cqYM7M2EH90vtJIpU8GqMrMMpKmtXvWLQwYIFhwaHyAFAQsFAh0=" + }, + { + "kind": "jupiter", + "quote": { + "inputMint": "So11111111111111111111111111111111111111112", + "inAmount": "10000000", + "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "outAmount": "1184404", + "otherAmountThreshold": "1178482", + "swapMode": "ExactIn", + "slippageBps": 50, + "platformFee": null, + "priceImpactPct": "0", + "routePlan": [ + { + "swapInfo": { + "ammKey": "8sLbNZoA1cfnvMJLPfp98ZLAnFSYCFApfJKMbiXNLwxj", + "label": "Raydium CLMM", + "inputMint": "So11111111111111111111111111111111111111112", + "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "inAmount": "10000000", + "outAmount": "1184404", + "updateContextSlot": "452698335" + }, + "percent": 100, + "bps": null + } + ], + "contextSlot": 452698335, + "timeTaken": 0.001680653, + "swapUsdValue": "1.184274687282227", + "mostReliableAmmsQuoteReport": { + "info": { + "BZtgQEyS6eXUXicYPHecYQ7PybqodXQMvkjUbP4R8mUU": "1184027", + "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE": "1183532" + } + }, + "longtailMarketQuoteReport": null, + "useIncurredSlippageForQuoting": null, + "useRewards": null, + "otherRoutePlans": null, + "loadedLongtailToken": false, + "instructionVersion": null, + "transactionVersion": 0 + }, + "userPublicKey": "4rGncwEhSW6AMgqCMhfRK5pBfUaoESHjgmpwCAGNsYDE", + "priorityFeeSol": 0.00005, + "transactionBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAGDDkyXs20dM2amjl+lRw5hmioQC4SfUY/fv4HZt/yLnxhBFIvHTy9cU6ZGAEQct8/4RrN/8hf5JAK+kqj6buJxi8VxD2CKFWEkjtOdicB9qjmiD92uF3zOrOdHCODGOpNXRnr8QT5vCtp92ymTmAZ0rwyovTDH+OqpBfAI5yjCiQ5yeUGZWXXPLnkaE0sLeZvSCEmGdUwDiBefu3/47seg0T0sL2Hhh4BbBfYDQMD5XHzKJIaVQgT7dGUjRhFNMFvHQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTjwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+Fm0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6E6YbftBJWQWE9WRj6d1axAXxHzJZR0wlUorssNB4Ql5CAcABQLAXBUABwAJA4KLAAAAAAAACgYAAwARBgkBAQYCAAMMAgAAAICWmAAAAAAACQEDAREKBgACABQGCQEBCBgJAAMCCBQICwgTABIPAwINDgwJARAEBQgj5RfLl3rjrSoBAAAAGmQAAYCWmAAAAAAAlBISAAAAAAAyAAAJAwMAAAEJAc8rFKNQA6+0Qab0nAoYoa2fxeGDaoO82W7pleCygUXQBY6YkJEZBAOVGBc=" + }, + { + "kind": "pump-bundle", + "args": { + "publicKey": "4rGncwEhSW6AMgqCMhfRK5pBfUaoESHjgmpwCAGNsYDE", + "action": "buy", + "mint": "2xHkesAQteG9yz48SDaVAtKdFU6Bvdo9sXS3uQCbpump", + "amount": 0.01, + "denominatedInSol": "true", + "slippage": 15, + "priorityFee": 0.0001, + "pool": "pump" + }, + "index": 0, + "transactionBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAFDTkyXs20dM2amjl+lRw5hmioQC4SfUY/fv4HZt/yLnxhiQd9VaW7EzB2Prdn9V7Ad7QaDQdffeHXP7rKPGPVVHESe+pa9tgPhvjhmWFZZrqLFATvf3UWvHXHcgzTIsNdj+tvo/Ky81bGOYcHXM//xEY6FHoRP46aQ6GKqnV0BA2NxzygekiTj7ZGL1PR4mzqc2f3kp743isdIIGwCTCalBtNNU2PCKM11h68a4/i7cIOfIgG9SPUXHFiXoo6O50P9A8UZtLYgoSqwaLGYbyutdrXDO7ymt/8OPAalPZatImdJL2T4TwQo6IrLJ7Kq2eVk+Yiq1W7hBkRB6iTgP5cC1MDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkdBZLdYY8VHvN8XAUnCw5IWCaWw4p4MK3aqoHlDzXyj9J7cvv2fNwhBhQa+fdKDwnZGu595jr45hnaBV/XE6EZPbZQFo0T+WEBPabBSXxym37lAqH+L4F8d3Xp19bVPJEECAAFAhCYAgAJAgABDAIAAACghgEAAAAAAAoGAAIACwkQAQEMExENCwMEAgAJEAUSExQGFRYHDg8aZjI9EgHa6+pUi3tgTAAAAGBXzgEAAAAAMgABaPO83kHcfHKmDOzNhB/dL7SSKVPBqjKzDKSprV71i0MDFhogBwgLBQIYHRw=" + }, + { + "kind": "pump-bundle", + "args": { + "publicKey": "4x3kBwjV6zJxoWTyR9uod7SLqAse1UgNYJgvb3NcGRBe", + "action": "buy", + "mint": "2xHkesAQteG9yz48SDaVAtKdFU6Bvdo9sXS3uQCbpump", + "amount": 0.01, + "denominatedInSol": "true", + "slippage": 15, + "priorityFee": 0, + "pool": "pump" + }, + "index": 1, + "transactionBase64": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAECzqtG9AYP/ChroSor4W8iWEF8TYFkj98PsSbOIuj1v3xqGCazgqCSkp8ByQvxSY5YWDCjeM7NRtW4nOy9zlQzf7rb6PysvNWxjmHB1zP/8RGOhR6ET+OmkOhiqp1dAQNjcc8oHpIk4+2Ri9T0eJs6nNn95Ke+N4rHSCBsAkwmpQbTTVNjwijNdYevGuP4u3CDnyIBvUj1FxxYl6KOjudD/TF7RZ4SuWVPFkztCUg4+nK+JTE6Mzlj7mkpCJkFli2giS9k+E8EKOiKyyeyqtnlZPmIqtVu4QZEQeok4D+XAtTAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WR0Fkt1hjxUe83xcBScLDkhYJpbDingwrdqqgeUPNfKP0nty+/Z83CEGFBr590oPCdka7n3mOvjmGdoFX9cToRk9tlAWjRP5YQE9psFJfHKbfuUCof4vgXx3denX1tU8kQMHAAUCEJgCAAgGAAEACQ4PAQEKExALCQIDAQAODwQREhMFFBUGDA0aZjI9EgHa6+rdUwFUTAAAAGBXzgEAAAAAMgABaPO83kHcfHKmDOzNhB/dL7SSKVPBqjKzDKSprV71i0MDFh8gCAEICwUCGB0c" + } + ] +} diff --git a/scripts/netcheck.ts b/scripts/netcheck.ts index d478a6f..a644857 100644 --- a/scripts/netcheck.ts +++ b/scripts/netcheck.ts @@ -30,7 +30,6 @@ const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const BONK = 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'; const WSOL = 'So11111111111111111111111111111111111111112'; -const { endpoints: endpointsForCheck } = await import('../src/config.js'); console.log('\n── Solana RPC ──'); @@ -396,13 +395,13 @@ await check('Jupiter covers most tokens still on their curve', async () => { let routable = 0; let tried = 0; for (const c of candidates) { - const quote = await fetch( - `${endpointsForCheck.jupiterQuote}?inputMint=${WSOL}&outputMint=${c.tokenAddress}` + - '&amount=10000000&slippageBps=1500', - ); tried++; - if (quote.ok) routable++; - await new Promise((r) => setTimeout(r, 150)); + try { + await getQuote({ inputMint: WSOL, outputMint: c.tokenAddress, amount: 10_000_000n, slippageBps: 1500 }); + routable++; + } catch { + // A missing route or unavailable response does not prove routability. + } } const pct = Math.round((routable / tried) * 100); diff --git a/scripts/portfolio-regressions.ts b/scripts/portfolio-regressions.ts index 9c5d7b3..baf50a3 100644 --- a/scripts/portfolio-regressions.ts +++ b/scripts/portfolio-regressions.ts @@ -10,14 +10,15 @@ process.env.BOT_TOKEN = '123:TEST'; process.env.OWNER_IDS = '1'; process.env.DATA_DIR = dataDir; process.env.VAULT_AUTOLOCK_MINUTES = '0'; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; const { initVaultWithKeyfile, lockVault } = await import('../src/store/vault.js'); const { generateSolanaWallet } = await import('../src/store/wallets.js'); const { db } = await import('../src/store/db.js'); const { rpc, WSOL_MINT } = await import('../src/chains/solana.js'); -const { clearPriceCache } = await import('../src/services/prices.js'); +const { clearPriceCache, getDexscreenerPrice } = await import('../src/services/prices.js'); const { buildPortfolio } = await import('../src/services/portfolio.js'); -const { showPnl, showPortfolio } = await import('../src/bot/handlers/core.js'); +const { showPnl, showPortfolio, showPositions } = await import('../src/bot/handlers/core.js'); const { renderPortfolio } = await import('../src/bot/ui.js'); const { PublicKey } = await import('@solana/web3.js'); const { TOKEN_PROGRAM_ID } = await import('@solana/spl-token'); @@ -103,10 +104,33 @@ try { await showPortfolio(ctx); assert.doesNotMatch(rendered, /on .* traded/); ok('a wallet group is not compared against the entire account cost basis'); + await showPositions(ctx); + assert.match(rendered, /Group one holdings/); + assert.doesNotMatch(rendered, /banked| in |[+-]\d+\.\d+%/); + ok('group position cards withhold account-wide cost and profit'); await showPnl(ctx); assert.equal(db.valueMarks().length, 1); ok('a complete account still records its value'); + db.updateSettings({ activeGroup: null }); + priceTokens = false; + clearPriceCache(); + await showPositions(ctx); + assert.match(rendered, /value unavailable/); + assert.doesNotMatch(rendered, /banked| in |[+-]\d+\.\d+%/); + ok('unpriced position cards cannot report a false total loss'); + failTokens = true; + await showPositions(ctx); + assert.match(rendered, /could not be read completely/); + assert.doesNotMatch(rendered, /No token positions/); + ok('failed holdings reads cannot report an empty position set'); + clearPriceCache(); + globalThis.fetch = async () => new Response(JSON.stringify({ pairs: [ + { baseToken: { address: WSOL_MINT.toLowerCase() }, priceUsd: '900', liquidity: { usd: 100_000 } }, + { baseToken: { address: WSOL_MINT }, priceUsd: '100', liquidity: { usd: 1_000 } }, + ] })); + assert.equal(await getDexscreenerPrice(WSOL_MINT), 100); + ok('Solana fallback pricing preserves case-sensitive mint identity'); } finally { connection.getMultipleAccountsInfo = originalMultiple; connection.getParsedTokenAccountsByOwner = originalTokens; diff --git a/scripts/reconcile-deep-regressions.ts b/scripts/reconcile-deep-regressions.ts new file mode 100644 index 0000000..d361137 --- /dev/null +++ b/scripts/reconcile-deep-regressions.ts @@ -0,0 +1,377 @@ +/** Adversarial history accounting fixtures. No RPC, signing, or broadcasts. */ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import bs58 from 'bs58'; +import { PublicKey, type ConfirmedSignatureInfo, type ParsedTransactionWithMeta } from '@solana/web3.js'; +import type { ReconcileServices } from '../src/services/reconcile.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-reconcile-deep-')); +process.env.BOT_TOKEN = '123:OFFLINE_TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = dataDir; +const { proceedsByMint, rebuildRealised } = await import('../src/services/reconcile.js'); +const { db } = await import('../src/store/db.js'); +const { withExecution, withExecutionMaintenance } = await import('../src/services/execution.js'); + +const key = (byte: number) => new PublicKey(new Uint8Array(32).fill(byte)); +const owner = key(10).toBase58(); +const input = key(11).toBase58(); +const poolInput = key(12).toBase58(); +const output = key(13).toBase58(); +const poolOutput = key(14).toBase58(); +const mint = key(15).toBase58(); +const outsider = key(16).toBase58(); +const otherMint = key(17).toBase58(); +const WSOL = 'So11111111111111111111111111111111111111112'; +const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const SYSTEM = '11111111111111111111111111111111'; +const PUMP = '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P'; +const JUPITER = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const PUMP_AMM = 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA'; +const RENT = 2_039_280; +const FEE = 5000; +const NET = (100_000_000 - FEE) / 1e9; +const swapIx = (program = PUMP, name = 'sell') => ({ + programId: new PublicKey(program), + accounts: [owner, input, output, mint, WSOL].map((s) => new PublicKey(s)), + // An Anchor discriminator plus two u64 swap arguments. + data: bs58.encode(Buffer.concat([ + createHash('sha256').update(`global:${name}`).digest().subarray(0, 8), Buffer.alloc(16), + ])), +}); +const tokenIx = (type: string, info: object) => ({ program: 'spl-token', programId: new PublicKey(TOKEN), parsed: { type, info } }); +const inputTransfer = (amount = '100000000') => tokenIx('transferChecked', { + source: input, destination: poolInput, authority: owner, mint, + tokenAmount: { amount, decimals: 6, uiAmount: Number(amount) / 1e6 }, +}); +const balance = (accountIndex: number, tokenMint: string, tokenOwner: string, amount: string, decimals = 6) => ({ + accountIndex, mint: tokenMint, owner: tokenOwner, + uiTokenAmount: { amount, decimals, uiAmount: Number(amount) / 10 ** decimals, uiAmountString: amount }, +}); +const sale = (): ParsedTransactionWithMeta => ({ + blockTime: 10_000, + slot: 1, + transaction: { signatures: [], message: { + accountKeys: [owner, input, poolInput, output, poolOutput, outsider].map((s, i) => ({ + pubkey: new PublicKey(s), signer: i === 0, writable: true, source: 'transaction', + })), + instructions: [swapIx()], + recentBlockhash: owner, + } }, + meta: { + err: null, fee: FEE, + preTokenBalances: [balance(1, mint, owner, '100000000'), balance(2, mint, outsider, '0')], + postTokenBalances: [balance(1, mint, owner, '0'), balance(2, mint, outsider, '100000000')], + preBalances: [1e9, RENT, RENT, RENT, 1e9, 1e9], + postBalances: [1e9 + 100_000_000 - FEE, RENT, RENT, RENT, 1e9, 1e9], + innerInstructions: [{ index: 0, instructions: [inputTransfer()] }], + }, +} as unknown as ParsedTransactionWithMeta); +const wrappedSale = (program = JUPITER, oldWrapped = 0): ParsedTransactionWithMeta => { + const tx = sale(); + tx.transaction.message.instructions = [ + swapIx(program, program === JUPITER ? 'route' : 'sell'), + tokenIx('closeAccount', { account: output, destination: owner, owner }), + ]; + tx.meta!.preTokenBalances!.push(balance(3, WSOL, owner, String(oldWrapped), 9)); + tx.meta!.preBalances[3] = RENT + oldWrapped; + tx.meta!.postBalances[3] = 0; + tx.meta!.postBalances[0] = 1e9 + 100_000_000 + RENT + oldWrapped - FEE; + tx.meta!.innerInstructions![0]!.instructions.push(tokenIx('transferChecked', { + source: poolOutput, destination: output, authority: outsider, mint: WSOL, + tokenAmount: { amount: '100000000', decimals: 9, uiAmount: 0.1 }, + })); + return tx; +}; +const signature = (name: string, blockTime: number | null = 10_000): ConfirmedSignatureInfo => ({ + signature: name, slot: 1, err: null, memo: null, blockTime, confirmationStatus: 'confirmed', +}); +const service = ( + pages: ConfirmedSignatureInfo[][], + parse: (name: string) => ParsedTransactionWithMeta | null, + parsedNames: string[] = [], +): ReconcileServices => { + let page = 0; + return { + rpc: () => ({ + getSignaturesForAddress: async () => pages[page++] ?? [], + getParsedTransactions: async (names) => names.map((name) => { parsedNames.push(name); return parse(name); }), + }), + paced: async (fn) => fn(), + }; +}; +const scan = (tx: ParsedTransactionWithMeta) => proceedsByMint(owner, 9_000_000, undefined, + service([[signature('fixture')]], () => tx)); +const closeTo = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`); +let passed = 0; +const check = (name: string) => { passed++; console.log(` ✓ ${name}`); }; +const rejects = async (tx: ParsedTransactionWithMeta, name: string) => { + const result = await scan(tx); + assert.equal(result.complete, false, 'unattributable accounting cannot be complete'); + assert.equal(result.found.size, 0, 'ambiguous SOL must never raise proceeds'); + check(name); +}; + +try { + { + const result = await scan(sale()); + assert.equal(result.complete, true); + closeTo(result.found.get(mint)!, NET); + check('a supported native Pump sell preserves measured return net of fees'); + } + { + const tx = sale(); + tx.transaction.message.instructions = [inputTransfer(), { + programId: new PublicKey(SYSTEM), + program: 'system', + parsed: { type: 'transfer', info: { source: outsider, destination: owner, lamports: 100_000_000 } }, + }]; + tx.meta!.innerInstructions = []; + await rejects(tx, 'token transfer plus unrelated SOL funding is not a sale'); + } + { + const tx = sale(); + tx.transaction.message.instructions = [swapIx(PUMP, 'collect_creator_fee')]; + await rejects(tx, 'a trade program called for another operation cannot prove a sale'); + } + { + const tx = sale(); + tx.transaction.message.instructions.push(inputTransfer()); + tx.meta!.innerInstructions = []; + await rejects(tx, 'a recognized swap does not authorize an unrelated token debit'); + } + { + const tx = sale(); + tx.meta!.innerInstructions![0]!.instructions = [inputTransfer('99000000')]; + await rejects(tx, 'the attributed CPI debit must explain the entire owned-token decrease'); + } + { + const tx = sale(); + tx.meta!.preTokenBalances!.push(balance(3, otherMint, owner, '1', 0)); + tx.meta!.postTokenBalances!.push(balance(3, otherMint, owner, '0', 0)); + await rejects(tx, 'proceeds are never split using incomparable quantities from two mints'); + } + { + const tx = sale(); + tx.meta!.postTokenBalances!.push(balance(3, otherMint, owner, '1000000')); + await rejects(tx, 'a transaction acquiring another token leaves SOL valuation ambiguous'); + } + { + const tx = sale(); + tx.meta!.innerInstructions = null; + await rejects(tx, 'missing CPI evidence cannot produce a complete repair'); + } + { + const tx = sale(); + tx.transaction.message.accountKeys[0]!.signer = false; + await rejects(tx, 'the proceeds wallet must participate as the swap signer'); + } + { + const tx = sale(); + tx.meta!.preTokenBalances![0]!.owner = undefined; + await rejects(tx, 'missing token ownership metadata is explicitly incomplete'); + } + for (const program of [JUPITER, PUMP_AMM]) { + const result = await scan(wrappedSale(program)); + assert.equal(result.complete, true); + closeTo(result.found.get(mint)!, NET); + check(`a supported ${program === JUPITER ? 'Jupiter' : 'PumpSwap'} sale excludes refunded WSOL rent`); + } + { + const result = await scan(wrappedSale(JUPITER, 500_000_000)); + assert.equal(result.complete, true); + closeTo(result.found.get(mint)!, NET); + check('redeeming pre-existing WSOL cannot inflate a new sale'); + } + { + const tx = wrappedSale(); + tx.meta!.preTokenBalances = tx.meta!.preTokenBalances!.filter((b) => b.mint !== WSOL); + tx.meta!.preBalances[3] = 0; + tx.meta!.postBalances[0] = 1e9 + 100_000_000 - FEE; + tx.transaction.message.instructions.unshift(tokenIx('initializeAccount3', { account: output, mint: WSOL, owner })); + tx.meta!.innerInstructions![0]!.index = 1; + const result = await scan(tx); + assert.equal(result.complete, true); + closeTo(result.found.get(mint)!, NET); + check('a temporary WSOL account absent from balance snapshots uses initialization evidence'); + } + { + const tx = wrappedSale(); + tx.meta!.innerInstructions![0]!.instructions = [inputTransfer()]; + await rejects(tx, 'WSOL account closure without swap payout does not prove proceeds'); + } + { + const tx = wrappedSale(); + tx.transaction.message.instructions.push({ + programId: new PublicKey(SYSTEM), + program: 'system', + parsed: { type: 'transfer', info: { source: outsider, destination: owner, lamports: 10_000 } }, + }); + tx.meta!.postBalances[0]! += 10_000; + await rejects(tx, 'even small independent native funding beside a valid swap is rejected'); + } + { + const tx = wrappedSale(); + tx.transaction.message.instructions[1] = tokenIx('closeAccount', { account: output, destination: outsider, owner }); + await rejects(tx, 'wrapped output must actually be redeemed to the proceeds wallet'); + } + { + const tx = sale(); + tx.transaction.message.instructions.push(tokenIx('closeAccount', { account: input, destination: owner, owner })); + tx.meta!.postBalances[1] = 0; + tx.meta!.postBalances[0] = 1e9 + RENT - FEE; + await rejects(tx, 'rent refunds alone cannot masquerade as sale return'); + } + { + const tx = sale(); + tx.transaction.message.instructions = [{ programId: key(25), accounts: [key(10), key(11)], data: '1' }]; + tx.meta!.innerInstructions![0]!.instructions.unshift(swapIx()); + await rejects(tx, 'an unfamiliar wrapper around a swap remains bounded and incomplete'); + } + { + const tx = sale(); + tx.transaction.message.instructions = [{ programId: key(25), accounts: [key(10), key(11)], data: '1' }]; + tx.meta!.postBalances[0] = 1e9 - FEE; + await rejects(tx, 'an unknown route returning a non-native asset cannot claim complete SOL accounting'); + } + { + const tx = sale(); + tx.transaction.message.instructions = [inputTransfer()]; + tx.meta!.innerInstructions = []; + tx.meta!.postBalances[0] = 1e9 - FEE; + const result = await scan(tx); + assert.equal(result.complete, true); + assert.equal(result.found.size, 0); + check('a plain token transfer with only transaction fees remains a known non-sale'); + } + { + const tx = sale(); + tx.meta!.preTokenBalances![0]!.uiTokenAmount.amount = '90071992547409931'; + tx.meta!.postTokenBalances![0]!.uiTokenAmount.amount = '90071992547409930'; + tx.meta!.innerInstructions![0]!.instructions = [inputTransfer('1')]; + const result = await scan(tx); + assert.equal(result.complete, true); + closeTo(result.found.get(mint)!, NET); + check('exact raw units preserve a debit hidden by floating-point display rounding'); + } + { + const parsedNames: string[] = []; + const result = await proceedsByMint(owner, 9_000_000, undefined, service([ + [signature('recent'), signature('old', 8000)], + ], () => sale(), parsedNames)); + assert.deepEqual(parsedNames, ['recent']); + assert.equal(result.complete, true); + assert.equal(result.scanned, 1); + closeTo(result.found.get(mint)!, NET); + check('a page crossing the history boundary excludes older signatures before parsing'); + } + { + const tx = sale(); + tx.blockTime = 8000; + const result = await scan(tx); + assert.equal(result.complete, true); + assert.equal(result.scanned, 0); + assert.equal(result.found.size, 0); + check('the parsed transaction timestamp enforces the boundary independently'); + } + { + const tx = sale(); + tx.blockTime = null; + const result = await proceedsByMint(owner, 9_000_000, undefined, + service([[signature('unknown', null)]], () => tx)); + assert.equal(result.complete, false); + assert.equal(result.scanned, 0); + assert.equal(result.found.size, 0); + check('an unknown timestamp cannot pull earlier sales into the current ledger'); + } + { + const tx = sale(); + tx.blockTime = 9000; + const result = await proceedsByMint(owner, 9_000_000, undefined, + service([[signature('exact', null)]], () => tx)); + assert.equal(result.complete, true); + closeTo(result.found.get(mint)!, NET); + check('a parsed timestamp at the exact history boundary is included'); + } + { + const result = await proceedsByMint(owner, 9_000_000, undefined, + service([[signature('repeat'), signature('repeat')]], () => sale())); + assert.equal(result.complete, false); + assert.equal(result.scanned, 1); + closeTo(result.found.get(mint)!, NET); + check('duplicate RPC signatures cannot count the same proceeds twice'); + } + { + const services = service([], () => sale()); + services.rpc = () => ({ + getSignaturesForAddress: async () => { throw new Error('offline unavailable'); }, + getParsedTransactions: async () => [], + }); + await assert.rejects(proceedsByMint(owner, 9_000_000, undefined, services), /offline unavailable/); + check('a first-page RPC failure remains an explicit failure'); + } + const prepareLedger = () => { + db.wipe(); + db.addWallet({ id: 'offline-wallet', kind: 'solana', address: owner, label: 'Offline wallet', + secret: '', groups: [], isMain: false, disabled: false, createdAt: 1 }); + db.recordBuy(mint, { solSpent: 0.2, fills: 1, tokensBought: 100, decimals: 6 }); + const tx = sale(); + tx.blockTime = Math.floor(Date.now() / 1000); + return { tx, services: service([[signature('history', tx.blockTime)]], () => tx) }; + }; + { + const { tx, services } = prepareLedger(); + let resets = 0; + services.rpc = () => ({ + getSignaturesForAddress: async () => [signature('history', tx.blockTime!)], + getParsedTransactions: async () => { + await withExecutionMaintenance(async () => { + db.wipe(); + db.recordBuy(mint, { solSpent: 0.05, fills: 1, tokensBought: 50, decimals: 6 }); + resets++; + }); + return [tx]; + }, + }); + await assert.rejects(rebuildRealised(undefined, services), /account changed/); + assert.equal(resets, 1); + assert.equal(db.position(mint)!.realisedSol, 0); + check('reset during an unlocked history read cancels application to a recreated ledger'); + } + { + const { tx, services } = prepareLedger(); + services.rpc = () => ({ + getSignaturesForAddress: async () => [signature('history', tx.blockTime!)], + getParsedTransactions: async () => { + await withExecution(async () => { db.recordSell(mint, 0.3, 1, 50); }); + return [tx]; + }, + }); + const result = await rebuildRealised(undefined, services); + assert.equal(result.complete, true); + assert.equal(result.repaired.length, 0); + assert.equal(db.position(mint)!.realisedSol, 0.3); + check('a concurrent sale already in the ledger cannot be overwritten by an older scan'); + } + { + const { tx, services } = prepareLedger(); + const startedAt = db.position(mint)!.firstBuyAt; + services.rpc = () => ({ + getSignaturesForAddress: async () => [signature('history', tx.blockTime!)], + getParsedTransactions: async () => { + await withExecution(async () => { db.position(mint)!.firstBuyAt = startedAt + 1000; }); + return [tx]; + }, + }); + const result = await rebuildRealised(undefined, services); + assert.equal(result.repaired.length, 0); + assert.equal(db.position(mint)!.realisedSol, 0); + check('a changed position identity cannot inherit proceeds from the scan-start record'); + } + console.log(`\n${passed} offline deep reconciliation regressions passed.`); +} finally { + fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/reconcile-regressions.ts b/scripts/reconcile-regressions.ts index 9a8b9f8..3f80d95 100644 --- a/scripts/reconcile-regressions.ts +++ b/scripts/reconcile-regressions.ts @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createHash } from 'node:crypto'; +import bs58 from 'bs58'; import type { ConfirmedSignatureInfo, ParsedTransactionWithMeta } from '@solana/web3.js'; import type { ReconcileServices } from '../src/services/reconcile.js'; @@ -16,13 +18,24 @@ const { proceedsByMint, rebuildRealised } = await import('../src/services/reconc const owner = '11111111111111111111111111111111'; const mint = 'offline-reconciliation-mint'; const sale = { - transaction: { message: { accountKeys: [{ pubkey: owner }] } }, + transaction: { message: { + accountKeys: [{ pubkey: owner, signer: true }, { pubkey: 'token-account' }], + instructions: [{ + programId: '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P', + accounts: [owner, 'token-account'], + data: bs58.encode(createHash('sha256').update('global:sell').digest().subarray(0, 8)), + }], + } }, meta: { err: null, - preTokenBalances: [{ mint, owner, uiTokenAmount: { uiAmount: 100 } }], - postTokenBalances: [{ mint, owner, uiTokenAmount: { uiAmount: 0 } }], - preBalances: [0], - postBalances: [100_000_000], + preTokenBalances: [{ accountIndex: 1, mint, owner, uiTokenAmount: { amount: '100', decimals: 0, uiAmount: 100 } }], + postTokenBalances: [{ accountIndex: 1, mint, owner, uiTokenAmount: { amount: '0', decimals: 0, uiAmount: 0 } }], + preBalances: [0, 2_039_280], + postBalances: [100_000_000, 2_039_280], + innerInstructions: [{ index: 0, instructions: [{ + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + parsed: { type: 'transfer', info: { source: 'token-account', destination: 'pool-token-account', amount: '100' } }, + }] }], }, } as unknown as ParsedTransactionWithMeta; const page = (prefix: string, count: number, blockTime = 10_000): ConfirmedSignatureInfo[] => @@ -85,7 +98,7 @@ try { { const pages = Array.from({ length: 12 }, (_, i) => page(`boundary-${i}`, 100, i === 11 ? 8_000 : 10_000)); const result = await proceedsByMint(owner, 9_000_000, undefined, mocks(pages)); - assert.equal(result.scanned, 1200); + assert.equal(result.scanned, 1100, 'transactions before the history boundary are excluded individually'); assert.equal(result.complete, true, 'the history boundary was established at the budget boundary'); check('the signature budget still permits a proven complete history boundary'); } diff --git a/scripts/safety-regressions.ts b/scripts/safety-regressions.ts new file mode 100644 index 0000000..1ad0181 --- /dev/null +++ b/scripts/safety-regressions.ts @@ -0,0 +1,133 @@ +/** Offline safety regressions: no network, signing, store writes or trades. */ +import assert from 'node:assert/strict'; +process.env.BOT_TOKEN = '123:SAFETY-TEST'; +process.env.OWNER_IDS = '1'; +process.env.DATA_DIR = '/tmp/solfleet-safety-regressions-unused'; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; + +const { Keypair, PublicKey } = await import('@solana/web3.js'); +const { + TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ExtensionType, getMintLen, + ACCOUNT_SIZE, AccountType, PAUSABLE_CONFIG_SIZE, +} = await import('@solana/spl-token'); +const { assessToken, DEFAULT_SAFETY } = await import('../src/services/safety.js'); +const { parseMintAccount, parseTrapExtensions, getMintAuthorities } = await import('../src/services/mintauth.js'); +const { getTokenInfo } = await import('../src/services/tokeninfo.js'); +const { rpc, getMintBalances } = await import('../src/chains/solana.js'); +const { bondingCurvePda, PUMP_PROGRAM_ID } = await import('../src/trade/curve.js'); +const { summariseLocks, DAY_MS } = await import('../src/services/locks.js'); +const { getJupTokenData } = await import('../src/services/jupdata.js'); +const { renderTokenCard } = await import('../src/bot/ui.js'); + +const clean = { + address: Keypair.generate().publicKey.toBase58(), chain: 'solana' as const, warnings: [], + mintAuthority: null, freezeAuthority: null, top10Pct: 10, creatorHoldsPct: 0, + liquidityUsd: 10_000, volume1h: 10_000, pairCreatedAt: Date.now() - 600_000, +}; +assert.equal(assessToken(clean).safe, true); +for (const key of ['top10Pct', 'creatorHoldsPct', 'lockerPct', 'insiderPct', 'launchDistPct', 'liquidityUsd', 'volume1h', 'pairCreatedAt', 'devMints', 'traders5m']) { + for (const bad of [NaN, Infinity, -1, 'unread', null]) { + assert.equal(assessToken({ ...clean, [key]: bad } as never).safe, false, `${key}=${String(bad)} must refuse`); + } +} +assert.equal(assessToken({ ...clean, top10Pct: undefined }).safe, false); +assert.equal(assessToken({ ...clean, top10Pct: 10, holdersUnavailable: true }).safe, false, 'indexed concentration cannot replace missing chain evidence'); +assert.equal(assessToken({ ...clean, top10Pct: 10, top10PctUpperBound: 50 }).safe, false, 'fragmented unsampled wallets cannot evade the concentration cap'); +assert.equal(assessToken({ ...clean, top10Pct: 10, top10PctUpperBound: 15 }).safe, true, 'a conservative bound below the cap can pass without an exhaustive scan'); +assert.equal(assessToken({ ...clean, creatorHoldsPct: 0, creatorBalanceUnavailable: true }).safe, false, 'indexed creator percentage cannot replace missing chain evidence'); +assert.equal(assessToken({ ...clean, mintAuthority: undefined }).safe, false); +assert.equal(assessToken({ ...clean, creator: 'known', creatorHoldsPct: undefined }).safe, false); +assert.equal(assessToken({ ...clean, isPumpFun: true, curveComplete: undefined, volume1h: undefined, liquidityUsd: undefined }).safe, false); +assert.equal(assessToken({ ...clean, chain: 'ethereum' }).safe, false); +assert.equal(assessToken({ ...clean, mintReadUnavailable: true }, { ...DEFAULT_SAFETY, requireRevokedAuthorities: false }).safe, false); +assert.equal(assessToken({ ...clean, token2022: true, traps: undefined }, { ...DEFAULT_SAFETY, requireRevokedAuthorities: false }).safe, false); +assert.equal(assessToken({ ...clean, token2022: true, traps: null } as never).safe, false); +assert.equal(assessToken(clean, { ...DEFAULT_SAFETY, maxTop10Pct: NaN }).safe, false); +assert.equal(assessToken(clean, { ...DEFAULT_SAFETY, minLiquidityUsd: null } as never).safe, false); +assert.equal(assessToken(clean, { ...DEFAULT_SAFETY, requireRevokedAuthorities: undefined } as never).safe, false); + +const mintData = Buffer.alloc(82); +mintData.writeBigUInt64LE(1000n, 36); mintData[44] = 0; mintData[45] = 1; +assert.ok(parseMintAccount(mintData)); +for (const [offset, value] of [[0, 2], [46, 2], [45, 0]] as const) { + const bad = Buffer.from(mintData); + if (offset === 45) bad[offset] = value; + else bad.writeUInt32LE(value, offset); + assert.equal(parseMintAccount(bad), null); +} +const pausable = Buffer.alloc(getMintLen([ExtensionType.PausableConfig])); +mintData.copy(pausable); pausable[ACCOUNT_SIZE] = AccountType.Mint; +pausable.writeUInt16LE(ExtensionType.PausableConfig, ACCOUNT_SIZE + 1); +pausable.writeUInt16LE(PAUSABLE_CONFIG_SIZE, ACCOUNT_SIZE + 3); +Keypair.generate().publicKey.toBuffer().copy(pausable, ACCOUNT_SIZE + 5); +const traps = parseTrapExtensions(pausable, TOKEN_2022_PROGRAM_ID.toBase58()); +assert.match(traps.join(' '), /pause authority/i); +assert.equal(assessToken({ ...clean, token2022: true, traps }, { ...DEFAULT_SAFETY, requireRevokedAuthorities: false }).safe, false); + +const now = Date.now(); +const streams = summariseLocks([{ deposited: 500n, withdrawn: 0n, canceledAt: 0, start: now - 365 * DAY_MS, end: now + 366 * DAY_MS, recipient: 'r' }], 1000n, now); +assert.equal(assessToken({ ...clean, top10Pct: 60, lockerPct: 50, lockedSupply: streams.locked }).safe, false, 'final stream date does not prove unavailable supply'); +assert.equal(assessToken({ ...clean, top10Pct: 40, lockerPct: 20, lockedSupply: [{ pct: 20, unlockAt: now + 1000 * DAY_MS }] }).safe, false, 'an unrelated vault cannot discount a counted locker'); +const card = renderTokenCard({ ...clean, isPumpFun: true, curveComplete: false, creator: 'known', creatorBalanceUnavailable: true, lockedSupply: streams.locked }); +assert.match(card, /potentially claimable/); +assert.match(card, /No concentration discount/); +assert.match(card, /holds: ❓ unknown/); +assert.throws(() => summariseLocks([{ deposited: 500n, withdrawn: 501n, canceledAt: 0, start: now, end: now + 1, recipient: 'r' }], 1000n, now)); + +const c = rpc(); +const originalFetch = globalThis.fetch; +const mint = clean.address; +const creator = Keypair.generate().publicKey; +const curveKey = bondingCurvePda(mint).toBase58(); +let mode: 'lower-index' | 'malformed-index' | 'fragmented-owner' | 'creator' | 'null-jup' = 'lower-index'; +let wrongOwner = false; +const curveData = Buffer.alloc(81); +curveData.writeBigUInt64LE(1000n, 8); curveData.writeBigUInt64LE(1_000_000_000n, 16); +curveData.writeBigUInt64LE(1000n, 40); creator.toBuffer().copy(curveData, 49); +const whale = Keypair.generate().publicKey.toBase58(); +Object.assign(c, { + getAccountInfo: async (key: InstanceType) => key.toBase58() === mint + ? { owner: wrongOwner ? PublicKey.default : TOKEN_PROGRAM_ID, data: mintData } + : key.toBase58() === curveKey && mode === 'creator' ? { owner: PUMP_PROGRAM_ID, data: curveData } : null, + getTokenSupply: async () => ({ context: { slot: 1 }, value: { amount: '1000', decimals: 0, uiAmount: 1000, uiAmountString: '1000' } }), + getTokenLargestAccounts: async () => ({ context: { slot: 1 }, value: Array.from({ length: mode === 'fragmented-owner' ? 20 : 1 }, () => ({ + address: Keypair.generate().publicKey, amount: mode === 'fragmented-owner' ? '20' : '500', + decimals: 0, uiAmount: null, uiAmountString: '0', + })) }), + getMultipleParsedAccounts: async (keys: unknown[]) => ({ context: { slot: 1 }, value: keys.map(() => ({ data: { parsed: { info: { mint, owner: mode === 'creator' ? creator.toBase58() : whale } } } })) }), + getMultipleAccountsInfo: async (keys: unknown[]) => keys.map(() => null), + getParsedTokenAccountsByOwner: async () => ({ context: { slot: 1 }, value: [{ pubkey: Keypair.generate().publicKey, account: { owner: TOKEN_PROGRAM_ID, data: { parsed: { type: 'account', info: { mint, owner: creator.toBase58(), tokenAmount: { amount: '500', decimals: 0 } } } } } }] }), + getProgramAccounts: async () => [], +}); +globalThis.fetch = async (input, init) => { + const url = String(input); + if (url.includes('dexscreener')) return new Response(JSON.stringify([{ chainId: 'solana', dexId: 'fixture', baseToken: { address: mint, name: 'Fixture', symbol: 'F' }, liquidity: { usd: 10_000 }, volume: { h1: 10_000 }, pairCreatedAt: now - 600_000 }])); + if (url.includes('rugcheck')) return new Response(JSON.stringify(mode === 'malformed-index' ? { topHolders: [{}] } : mode === 'creator' + ? { creator: creator.toBase58(), token: { supply: 1000 }, creatorBalance: 500, topHolders: [{ owner: creator.toBase58(), pct: 50 }, { owner: whale, pct: 10 }], knownAccounts: { [creator.toBase58()]: { type: 'CREATOR' } } } + : { topHolders: [{ owner: whale, pct: 10 }] })); + if (url.includes('/tokens/v2/search')) return new Response(JSON.stringify(mode === 'null-jup' ? [{ id: mint, audit: { topHoldersPercentage: null } }] : [])); + if (init?.method === 'POST') return new Response(JSON.stringify({ result: { accounts: [], paginationKey: null } })); + return new Response('{}'); +}; +try { + wrongOwner = true; + assert.equal(await getMintAuthorities(mint), null, 'non-token owned data must not read as a mint'); + wrongOwner = false; + assert.equal((await getMintBalances([creator.toBase58()], mint)).get(creator.toBase58()), 500n, 'non-ATA balances are read exhaustively'); + for (const testMode of ['lower-index', 'malformed-index', 'fragmented-owner', 'creator'] as const) { + mode = testMode; + const info = await getTokenInfo(mint, 'solana'); + assert.equal(assessToken(info).safe, false, `${mode} must refuse`); + if (mode === 'fragmented-owner') { + assert.equal(info.top10Pct, 40, 'token accounts must aggregate by wallet'); + assert.equal(info.top10PctUpperBound, 100, 'every unsampled token is included in the worst-case bound'); + } + else assert.ok(info.top10Pct! >= 50, 'an index must never reduce observed chain concentration'); + if (mode === 'creator') assert.equal(info.creatorHoldsPct, 50, 'creator non-ATA balance remains counted'); + } + mode = 'null-jup'; + assert.equal((await getJupTokenData(mint))?.topHoldersPct, undefined, 'nullable audit field is unknown, not zero'); +} finally { + globalThis.fetch = originalFetch; +} +console.log('Safety regressions passed (offline).'); diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 4839fef..7c63240 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -6,6 +6,7 @@ process.env.BOT_TOKEN = '123:TEST'; process.env.OWNER_IDS = '1'; process.env.DATA_DIR = './.smoke-data'; process.env.VAULT_AUTOLOCK_MINUTES = '0'; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; import fs from 'node:fs'; import assert from 'node:assert/strict'; @@ -2255,7 +2256,7 @@ assert.ok(empty >= oneAttempt * 2n, 'two attempts are covered, not one'); ok('the reserve covers a retry, because a failed exit is tried again'); const engSrc = fs.readFileSync('src/trade/engine.ts', 'utf8'); -assert.match(engSrc, /const holdsTokens = holdings\.some/, 'the sweep asks what the wallet holds'); +assert.match(engSrc, /const holdsTokens = holdings === undefined \|\| holdings\.some/, 'unknown token balances retain the exit reserve'); assert.match(engSrc, /Math\.max\(settings\.sweepReserveSol, floorSol\)/, 'and never leaves less than the floor'); assert.match(engSrc, /EXIT_FEE_HEADROOM/, 'sized for the fee a stop actually pays, not the routine one'); ok('the sweep reserves against what it is leaving behind'); @@ -2609,7 +2610,7 @@ db.addCopyTarget({ assert.equal(db.copyTargets()[0]!.takeProfitSellPct, 75, 'a deliberate 75 is kept'); ok('only the unchosen default is rewritten, not a real choice'); -console.log('\n[47] Locked supply is not concentration'); +console.log('\n[47] Vesting evidence does not waive concentration'); /* * The launch that produced this section. A coin read 63.5% concentrated and @@ -2755,7 +2756,7 @@ const ninetyDay = summariseLocks( SUPPLY, LOCK_NOW, ); -assert.ok(lockedBeyond(ninetyDay.locked, 30 * DAY_MS, LOCK_NOW) > 49, 'discounted at a 30-day horizon'); +assert.ok(lockedBeyond(ninetyDay.locked, 30 * DAY_MS, LOCK_NOW) > 49, 'stream end remains beyond the 30-day display horizon'); assert.ok(lockedBeyond(ninetyDay.locked, 90 * DAY_MS, LOCK_NOW) > 49, 'and at 90 days'); assert.equal(lockedBeyond(ninetyDay.locked, YEAR_MS, LOCK_NOW), 0, 'but not at a year'); @@ -2807,16 +2808,13 @@ assert.ok(!beforeLocks.safe, 'without the lock data it is refused'); assert.match(beforeLocks.reasons[0] ?? '', /63\.6% of supply/, 'on the raw 63.6%'); const withLocks = assessToken(lizard, OPERATOR); -assert.ok(withLocks.safe, `with it the coin passes: ${withLocks.reasons.join(' ')}`); -assert.ok( - withLocks.notes.some((n) => /50\.2% of supply is locked until 2095/.test(n)), - 'and says why, rather than quietly softening the number', -); +assert.ok(!withLocks.safe, 'unmatched vesting streams cannot discount concentrated holders'); +assert.match(withLocks.reasons.join(' '), /63\.6% of supply/); assert.ok( withLocks.notes.some((n) => /11\.1% went to 9 wallets at launch/.test(n)), 'while naming the 11% the index scored at zero', ); -ok('the coin that started this passes, for stated reasons'); +ok('unmatched vesting balances cannot waive the concentration limit'); /* * Fail closed. A lookup that did not answer must not hand out a discount — a @@ -2830,13 +2828,12 @@ for (const gap of [{ lockerPct: undefined }, { lockedSupply: undefined }]) { ok('a lock nobody could verify discounts nothing'); /* - * A near lock is not a discount at every setting the screen offers. Ninety-one - * days out is discounted at the shorter horizons and counts in full at a year. + * A nearer stream end cannot authorize a concentration discount at any horizon. */ const nearLock = { ...lizard, lockedSupply: [{ pct: 50.23, unlockAt: Date.now() + 91 * DAY_MS }] }; -assert.ok(assessToken(nearLock, { ...OPERATOR, lockHorizonDays: 90 }).safe, 'discounted at 90 days'); -assert.ok(!assessToken(nearLock, { ...OPERATOR, lockHorizonDays: 365 }).safe, 'and not at a year'); -ok('the setting actually changes the verdict'); +assert.ok(!assessToken(nearLock, { ...OPERATOR, lockHorizonDays: 90 }).safe, 'no discount at 90 days without matched vault proof'); +assert.ok(!assessToken(nearLock, { ...OPERATOR, lockHorizonDays: 365 }).safe, 'no discount at a year either'); +ok('the display horizon cannot waive an unproven concentration risk'); /* * The other half. The launch distribution has to be able to refuse on its own, @@ -2869,14 +2866,14 @@ assert.match( ); ok('the lock scan is billed at a tenth, and still reads every page'); -// and it is reachable: a button cycling the three horizons, and a route to it +// The former discount control must not promise a safety exemption. const tradeSrc47 = fs.readFileSync('src/bot/handlers/trade.ts', 'utf8'); -assert.match(tradeSrc47, /const LOCK_STEPS = \[30, 90, 365\]/, 'the three horizons are the offered steps'); -assert.match(tradeSrc47, /'safety_lock'/, 'on a button'); -assert.match(fs.readFileSync('src/bot/index.ts', 'utf8'), /case 'safety_lock':/, 'that is routed'); +assert.doesNotMatch(tradeSrc47, /Ignore supply locked/, 'no unsupported concentration exemption'); +assert.doesNotMatch(tradeSrc47, /'safety_lock'/, 'the obsolete discount control is not emitted'); +assert.match(fs.readFileSync('src/bot/index.ts', 'utf8'), /case 'safety_lock':/, 'old buttons remain routable'); db.wipe(); assert.equal(db.settings().copySafety.lockHorizonDays, 365, 'shipping at a year, the strict end'); -ok('the horizon is a button, and it starts where it is safest'); +ok('legacy horizon settings are retained without a misleading discount control'); fs.rmSync(DATA, { recursive: true, force: true }); console.log(`\n✅ ${passed} assertions passed\n`); diff --git a/scripts/transaction-regressions.ts b/scripts/transaction-regressions.ts index 28f59d1..719b8eb 100644 --- a/scripts/transaction-regressions.ts +++ b/scripts/transaction-regressions.ts @@ -4,8 +4,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import bs58 from 'bs58'; -import { ComputeBudgetInstruction, Keypair, SystemProgram, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; -import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { createHash } from 'node:crypto'; +import { ComputeBudgetInstruction, Keypair, PublicKey, SystemProgram, TransactionInstruction, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, NATIVE_MINT, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createSyncNativeInstruction, createCloseAccountInstruction } from '@solana/spl-token'; const temporaryData = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-transactions-')); @@ -13,10 +14,12 @@ process.env.BOT_TOKEN = '123:TEST'; process.env.OWNER_IDS = '1'; process.env.DATA_DIR = temporaryData; process.env.VAULT_AUTOLOCK_MINUTES = '0'; +process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; process.env.SOLANA_RPC_URL = 'http://127.0.0.1:8899'; process.env.SOLANA_SEND_RPC_URL = process.env.SOLANA_RPC_URL; const { getBundleStatus, sendBundle, waitForBundle } = await import('../src/trade/jito.js'); +const { endpoints } = await import('../src/config.js'); const { TransactionRejectedError, TransactionSubmissionUnknownError } = await import('../src/trade/errors.js'); const { rpc, sendAndConfirm, signatureLanded, priorityFeeInstructions } = await import('../src/chains/solana.js'); const originalFetch = globalThis.fetch; @@ -46,6 +49,35 @@ function transaction(signer: Keypair, blockhash = Keypair.generate().publicKey.t return tx; } +/** Venue envelopes for status tests; trade correctness is not simulated here. */ +function tradeTransaction(signer: Keypair, mint: string, venue: 'pump' | 'jupiter' = 'pump'): VersionedTransaction { + const mintKey = new PublicKey(mint); + const instructions: TransactionInstruction[] = []; + const nativeAta = getAssociatedTokenAddressSync(NATIVE_MINT, signer.publicKey); + if (venue === 'jupiter') { + instructions.push( + createAssociatedTokenAccountIdempotentInstruction(signer.publicKey, nativeAta, signer.publicKey, NATIVE_MINT), + SystemProgram.transfer({ fromPubkey: signer.publicKey, toPubkey: nativeAta, lamports: 10_000_000 }), + createSyncNativeInstruction(nativeAta), + ); + } + instructions.push(new TransactionInstruction({ + programId: new PublicKey(venue === 'pump' + ? '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P' + : 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'), + keys: [{ pubkey: signer.publicKey, isSigner: true, isWritable: true }, { pubkey: mintKey, isSigner: false, isWritable: false }], + data: Buffer.concat([createHash('sha256').update(`global:${venue === 'pump' ? 'buy' : 'route'}`).digest().subarray(0, 8), Buffer.alloc(16, 1)]), + })); + if (venue === 'jupiter') instructions.push(createCloseAccountInstruction(nativeAta, signer.publicKey, signer.publicKey)); + const tx = new VersionedTransaction(new TransactionMessage({ + payerKey: signer.publicKey, recentBlockhash: Keypair.generate().publicKey.toBase58(), instructions, + }).compileToV0Message()); + // Deterministic signature of this message gives the status assertions their + // expected identity. External signing discards this supplied signature. + tx.sign([signer]); + return tx; +} + function confirmed(): ReturnType { return Promise.resolve({ context: { slot: 1 }, value: [{ slot: 1, confirmations: 1, err: null, confirmationStatus: 'confirmed' }] }); } @@ -192,7 +224,7 @@ try { globalThis.fetch = async (input) => { assert.equal(String(input), 'https://pumpportal.fun/api/trade-local'); builds++; - const built = transaction(signer); + const built = tradeTransaction(signer, request.mint); builtSignature = bs58.encode(built.signatures[0]!); return new Response(built.serialize()); }; @@ -234,12 +266,16 @@ try { globalThis.fetch = async (input, init) => { const url = String(input); if (url === 'https://pumpportal.fun/api/trade-local') return new Response('unavailable', { status: 400 }); - if (url.startsWith('https://lite-api.jup.ag/swap/v1/quote?')) { - return new Response(JSON.stringify({ outAmount: '100', inAmount: '10000000', routePlan: [] })); + if (url.startsWith(`${endpoints.jupiterQuote}?`)) { + return new Response(JSON.stringify({ + inputMint: NATIVE_MINT.toBase58(), outputMint: request.mint, inAmount: '10000000', outAmount: '100', + otherAmountThreshold: '95', swapMode: 'ExactIn', slippageBps: 500, priceImpactPct: '0', + routePlan: [{ swapInfo: { label: 'Offline venue fixture' }, percent: 100 }], platformFee: null, + })); } - if (url === 'https://lite-api.jup.ag/swap/v1/swap') { + if (url === endpoints.jupiterSwap) { assert.ok(JSON.parse(String(init?.body)).userPublicKey === wallet.address); - return new Response(JSON.stringify({ swapTransaction: Buffer.from(transaction(signer).serialize()).toString('base64') })); + return new Response(JSON.stringify({ swapTransaction: Buffer.from(tradeTransaction(signer, request.mint, 'jupiter').serialize()).toString('base64') })); } throw new Error(`Unexpected offline request: ${url}`); }; @@ -254,7 +290,7 @@ try { globalThis.fetch = async (input, init) => { if (String(input) === 'https://pumpportal.fun/api/trade-local') { assert.ok(Array.isArray(JSON.parse(String(init?.body)))); - return new Response(JSON.stringify([bs58.encode(transaction(signer).serialize())])); + return new Response(JSON.stringify([bs58.encode(tradeTransaction(signer, request.mint).serialize())])); } if (String(input) === 'https://mainnet.block-engine.jito.wtf/api/v1/bundles') throw new Error('Jito send response lost'); throw new Error(`Unexpected offline request: ${String(input)}`); diff --git a/src/bot/handlers/core.ts b/src/bot/handlers/core.ts index bb6f378..5088c34 100644 --- a/src/bot/handlers/core.ts +++ b/src/bot/handlers/core.ts @@ -14,7 +14,9 @@ import { positionPnl, entryPrice, accountPnl } from '../../services/pnl.js'; import { getSolBalances, LAMPORTS } from '../../chains/solana.js'; import { fmtAmount, fmtUsd, fmtPriceUsd, errMessage } from '../../util.js'; import { log } from '../../logger.js'; -import { tokenId, setPending, clearSession, stageConfirmation } from '../session.js'; +import { tokenId, setPending, clearAllSessions, stageConfirmation } from '../session.js'; +import { withExecutionMaintenance } from '../../services/execution.js'; +import { stopSubscriptions } from '../../services/copytrade.js'; import { mainMenu, renderPortfolio, @@ -214,7 +216,7 @@ export async function rebuildPnl(ctx: Context): Promise { ? '⚠️ Nothing could be read. This is not an all-clear — the scan never got going.' : `⚠️ Only part of the history was read (${result.walletsRead}/${result.walletsTotal} wallets, ` + `${result.transactionsScanned.toLocaleString('en-US')} transactions). More may still be missing.`, - 'The provider rate limited the scan. Run it again — it picks up whatever it finds.', + 'Some history was unavailable or its sale proceeds could not be attributed. Unsupported or ambiguous transactions remain incomplete.', ); if (result.failures.length > 0) { lines.push(`${h(result.failures.slice(0, 2).join(' | '))}`); @@ -287,7 +289,7 @@ export async function showPositions(ctx: Context): Promise { if (positions.length === 0) { await render( ctx, - `🪙 Positions\n\nNo token positions across the selected wallets.\n\n${updatedStamp()}`, + `🪙 Positions\n\n${portfolio.errors.length > 0 ? 'Token holdings could not be read completely. Refresh before relying on this view.' : 'No token positions across the selected wallets.'}\n\n${updatedStamp()}`, new InlineKeyboard() .text('🔄 Refresh', 'positions').primary() .text('📈 P&L', 'pnl').primary() @@ -301,6 +303,8 @@ export async function showPositions(ctx: Context): Promise { const unsolicited = positions.filter((p) => !p.boughtHere); const lines = ['🪙 Positions', '']; + if (settings.activeGroup !== null) lines.push(`Group ${h(settings.activeGroup)} holdings; account-wide profit is on P&L.`, ''); + if (portfolio.errors.length > 0) lines.push('Known holdings only. Valuation is incomplete; profit figures are withheld.', ''); const kb = new InlineKeyboard(); const solPrice = portfolio.totals.solPriceUsd; @@ -318,13 +322,14 @@ export async function showPositions(ctx: Context): Promise { for (const p of owned.slice(0, 12)) { const record = db.position(p.mint); const valueSol = solPrice > 0 ? p.totalUsd / solPrice : 0; - const pnl = record && record.investedSol > 0 ? positionPnl(record, valueSol) : null; + const pnl = settings.activeGroup === null && portfolio.errors.length === 0 && !p.unpriced && solPrice > 0 && record && record.investedSol > 0 + ? positionPnl(record, valueSol) : null; const light = pnl === null ? '·' : pnl.netSol >= 0 ? '🟢' : '🔴'; const move = pnl === null ? '' : ` ${pnl.netPct >= 0 ? '+' : ''}${pnl.netPct.toFixed(1)}%`; - lines.push(`${light} ${h(p.symbol)} ${fmtUsd(p.totalUsd)}${move}`); + lines.push(`${light} ${h(p.symbol)} ${p.unpriced ? 'value unavailable' : fmtUsd(p.totalUsd)}${move}`); - if (record && record.investedSol > 0) { + if (record && pnl) { const nowSol = p.totalAmount > 0 ? valueSol / p.totalAmount : null; const entry = entryPrice(record); if (entry !== null) { @@ -332,9 +337,9 @@ export async function showPositions(ctx: Context): Promise { lines.push(` entry ${fmtPriceUsd(entry * solPrice)}${arrow}`); } - const banked = pnl!.realisedSol > 0 ? ` · banked ${pnl!.realisedSol.toFixed(3)} ◎` : ''; + const banked = pnl.realisedSol > 0 ? ` · banked ${pnl.realisedSol.toFixed(3)} ◎` : ''; lines.push( - ` in ${pnl!.investedSol.toFixed(3)} ◎ · worth ${valueSol.toFixed(3)} ◎${banked}`, + ` in ${pnl.investedSol.toFixed(3)} ◎ · worth ${valueSol.toFixed(3)} ◎${banked}`, ); } @@ -461,15 +466,17 @@ export async function executeFactoryReset(ctx: Context, text: string): Promise { + await stopSubscriptions(); + const count = allWallets().length; + destroyVault(); + db.wipe(); + clearAllSessions(); - destroyVault(); - db.wipe(); - clearSession(ctx.from!.id); - - // A reset that left no vault would leave the bot unusable until a restart, - // since nothing else creates one any more. Start the empty one right away. - initVaultWithKeyfile(); + // Start the empty vault only after old operations have stopped using it. + initVaultWithKeyfile(); + return count; + }); log.warn(`Factory reset performed. ${had} wallets and the vault were deleted.`); @@ -583,7 +590,11 @@ export async function promptForgetLegacy(ctx: Context): Promise { } const id = stageConfirmation(ctx.from!.id, `delete ${legacy.length} legacy wallets`, async (confirmCtx) => { - const removed = forgetLegacyWallets(); + const removed = await withExecutionMaintenance(async () => { + const count = forgetLegacyWallets(); + clearAllSessions(); + return count; + }); log.warn(`Deleted ${removed} legacy wallet record(s).`); await render( confirmCtx, diff --git a/src/bot/handlers/trade.ts b/src/bot/handlers/trade.ts index 4504140..4009c3c 100644 --- a/src/bot/handlers/trade.ts +++ b/src/bot/handlers/trade.ts @@ -1,12 +1,12 @@ import type { Context } from 'grammy'; import { InlineKeyboard } from 'grammy'; import { config } from '../../config.js'; -import { db, type CopyTarget, type CopyExitMode } from '../../store/db.js'; -import { selectWallets, mainWallet } from '../../store/wallets.js'; +import { db, type CopyTarget, type CopyExitMode, type Settings } from '../../store/db.js'; +import { selectWallets, allWallets, mainWallet } from '../../store/wallets.js'; import { getTokenInfo, extractTokenAddress } from '../../services/tokeninfo.js'; import { getSolPrice } from '../../services/prices.js'; import { positionPnl, formatPnl, formatEntry, exitResult, formatExit } from '../../services/pnl.js'; -import { getMintBalances, getSolBalance, LAMPORTS } from '../../chains/solana.js'; +import { getMintBalances, getMintDecimals, getSolBalance, LAMPORTS } from '../../chains/solana.js'; import { simulateSequentialBuys, fetchBondingCurve } from '../../trade/curve.js'; import { batchPumpTrade, @@ -43,8 +43,9 @@ import { import { render } from './core.js'; import { newRuleId, entryPriceSol, describe as describeRule } from '../../services/watcher.js'; import { priceInSol } from '../../services/price.js'; -import { describeLimits, formatAge, formatHorizon } from '../../services/safety.js'; -import type { TradeRequest } from '../../types.js'; +import { withExecution } from '../../services/execution.js'; +import { describeLimits, formatAge } from '../../services/safety.js'; +import type { TradeRequest, WalletRecord } from '../../types.js'; /** * Telegram rate limits message edits hard. Batches of 50 wallets would otherwise @@ -89,18 +90,21 @@ export async function showTokenCard(ctx: Context, mint: string, replace = false) // cost ten sequential round trips to do it. let holdsPosition = false; let heldRaw = 0n; + let holdingKnown = false; if (info.chain === 'solana') { try { const wallets = selectWallets(); - const held = await getMintBalances(wallets.map((w) => w.address), mint); - holdsPosition = held.size > 0; + const held = await getMintBalances(allWallets().map((w) => w.address), mint); + holdsPosition = wallets.some((w) => (held.get(w.address) ?? 0n) > 0n); for (const amount of held.values()) heldRaw += amount; + holdingKnown = true; } catch { /* a failed balance read should not hide the card */ } } - const heldTokens = Number(heldRaw) / 10 ** (info.decimals ?? 6); + const decimals = info.decimals ?? await getMintDecimals(mint).catch(() => undefined); + const heldTokens = decimals === undefined ? undefined : Number(heldRaw) / 10 ** decimals; const solPriceUsd = await getSolPrice().catch(() => 0); // judged against the same limits copy trading uses, so the card and the @@ -109,7 +113,8 @@ export async function showTokenCard(ctx: Context, mint: string, replace = false) // what this position has cost and returned, if it was bought through here const record = db.position(mint); - if (record && record.investedSol > 0) { + if (record && record.investedSol > 0 && holdingKnown && heldTokens !== undefined && + info.priceUsd !== undefined && solPriceUsd > 0) { const heldSol = info.priceUsd !== undefined && solPriceUsd > 0 ? (heldTokens * info.priceUsd) / solPriceUsd @@ -181,8 +186,8 @@ export async function showHolders(ctx: Context, mint: string): Promise { // ── buying ──────────────────────────────────────────────────────────────────── export async function promptBuy(ctx: Context, mint: string, solPerWallet: number): Promise { - const settings = db.settings(); - const wallets = selectWallets(); + const settings = structuredClone(db.settings()); + const wallets = structuredClone(selectWallets()); // rejections come first, while the tap can still be answered with an alert if (wallets.length === 0) { @@ -291,7 +296,7 @@ export async function promptBuy(ctx: Context, mint: string, solPerWallet: number } const run = async (confirmCtx: Context) => { - await executeBuy(confirmCtx, mint, solPerWallet); + await executeBuy(confirmCtx, mint, solPerWallet, wallets, settings); }; if (!config.safety.requireConfirmation) { @@ -303,9 +308,11 @@ export async function promptBuy(ctx: Context, mint: string, solPerWallet: number await render(ctx, lines.join('\n'), confirmKeyboard(id, `tokeninfo:${tokenId(mint)}`)); } -async function executeBuy(ctx: Context, mint: string, solPerWallet: number): Promise { - const settings = db.settings(); - const wallets = selectWallets(); +async function executeBuy(ctx: Context, mint: string, solPerWallet: number, wallets: WalletRecord[], settings: Settings): Promise { + return withExecution(() => executeBuyLocked(ctx, mint, solPerWallet, wallets, settings)); +} + +async function executeBuyLocked(ctx: Context, mint: string, solPerWallet: number, wallets: WalletRecord[], settings: Settings): Promise { const request: TradeRequest = { action: 'buy', @@ -322,7 +329,9 @@ async function executeBuy(ctx: Context, mint: string, solPerWallet: number): Pro // Tokens received are measured, not quoted: the entry price every auto-sell // rule is measured against comes from what the batch actually acquired. const buyAddresses = wallets.map((w) => w.address); - const heldBefore = await getMintBalances(buyAddresses, mint).catch(() => undefined); + const accountAddresses = [...new Set([...buyAddresses, ...allWallets().map((w) => w.address)])]; + const heldBefore = await getMintBalances(accountAddresses, mint).catch(() => undefined); + const decimals = await getMintDecimals(mint).catch(() => undefined); try { const summary = await batchPumpTrade( @@ -342,10 +351,13 @@ async function executeBuy(ctx: Context, mint: string, solPerWallet: number): Pro }); // cost basis: only the wallets that actually filled spent anything - const fills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (uncertain) db.invalidateBasis(mint); const bought = await getTokenInfo(mint, 'solana').catch(() => null); - const tokensGained = await measureTokensGained(buyAddresses, mint, heldBefore, bought?.decimals); + const tokensGained = await measureTokensGained(filled.map((r) => r.address), mint, heldBefore, decimals); db.recordBuy(mint, { solSpent: solPerWallet * fills, @@ -354,12 +366,14 @@ async function executeBuy(ctx: Context, mint: string, solPerWallet: number): Pro symbol: bought?.symbol, costSol: summary.solSpent, freshEntry: isFreshEntry(heldBefore), - decimals: bought?.decimals, + decimals, + quantityComplete: !uncertain, }); await render( ctx, - renderBatchSummary(`🟢 Bought ${solPerWallet} SOL × ${wallets.length}`, summary), + renderBatchSummary(`🟢 Bought ${solPerWallet} SOL × ${wallets.length}`, summary) + + (uncertain ? '\n\nSome trades may still land. Entry basis is unknown; check the wallets before another order.' : ''), new InlineKeyboard() .text('🔄 Token', `tokeninfo:${tokenId(mint)}`) .text('🪙 Positions', 'positions') @@ -374,8 +388,8 @@ async function executeBuy(ctx: Context, mint: string, solPerWallet: number): Pro // ── selling ─────────────────────────────────────────────────────────────────── export async function promptSell(ctx: Context, mint: string, percent: number): Promise { - const settings = db.settings(); - const wallets = selectWallets(); + const settings = structuredClone(db.settings()); + const wallets = structuredClone(selectWallets()); if (wallets.length === 0) { await ctx.answerCallbackQuery({ text: 'No Solana wallets selected.', show_alert: true }); @@ -392,7 +406,7 @@ export async function promptSell(ctx: Context, mint: string, percent: number): P ]; const run = async (confirmCtx: Context) => { - await executeSell(confirmCtx, mint, percent); + await executeSell(confirmCtx, mint, percent, wallets, settings); }; if (!config.safety.requireConfirmation) { @@ -404,9 +418,11 @@ export async function promptSell(ctx: Context, mint: string, percent: number): P await render(ctx, lines.join('\n'), confirmKeyboard(id, `tokeninfo:${tokenId(mint)}`)); } -async function executeSell(ctx: Context, mint: string, percent: number): Promise { - const settings = db.settings(); - const wallets = selectWallets(); +async function executeSell(ctx: Context, mint: string, percent: number, wallets: WalletRecord[], settings: Settings): Promise { + return withExecution(() => executeSellLocked(ctx, mint, percent, wallets, settings)); +} + +async function executeSellLocked(ctx: Context, mint: string, percent: number, wallets: WalletRecord[], settings: Settings): Promise { const request: TradeRequest = { action: 'sell', @@ -439,15 +455,19 @@ async function executeSell(ctx: Context, mint: string, percent: number): Promise // the engine measures this for every sell now, so the manual screen no // longer needs its own copy of the arithmetic — and every other exit path // gets the recording that only this one used to have - const fills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (uncertain) db.invalidateBasis(mint); const position = db.position(mint); - const tokensSold = await measureTokensSold(addresses, mint, heldBefore, position?.decimals); + const decimals = position?.decimals ?? await getMintDecimals(mint).catch(() => undefined); + const tokensSold = await measureTokensSold(filled.map((r) => r.address), mint, heldBefore, decimals); const sellOutcome = summary.solReceived !== undefined ? exitResult(position, tokensSold, summary.solReceived) : null; - if (summary.solReceived !== undefined && fills > 0) { - db.recordSell(mint, summary.solReceived, fills); + if (fills > 0) { + db.recordSell(mint, summary.solReceived ?? 0, fills, uncertain ? undefined : tokensSold || undefined); } db.appendTradeLog({ @@ -462,6 +482,7 @@ async function executeSell(ctx: Context, mint: string, percent: number): Promise await render( ctx, renderBatchSummary(`🔴 Sold ${percent}% × ${wallets.length}`, summary) + + (uncertain ? '\n\nSome trades may still land. Entry basis and proceeds are unknown; check the wallets.' : '') + (sellOutcome ? `\n\n${formatExit(sellOutcome)}` : ''), new InlineKeyboard() .text('🔄 Token', `tokeninfo:${tokenId(mint)}`) @@ -479,7 +500,7 @@ async function executeSell(ctx: Context, mint: string, percent: number): Promise export async function promptSellEverything(ctx: Context): Promise { const wallets = selectWallets(); - const run = async (ctx: Context) => { + const run = async (ctx: Context) => withExecution(async () => { await render(ctx, '🔥 Selling every position…\n\nDiscovering token accounts…'); try { @@ -537,7 +558,7 @@ export async function promptSellEverything(ctx: Context): Promise { } catch (err) { await render(ctx, `❌ ${h(errMessage(err))}`, backButton()); } - }; + }); const id = stageConfirmation(ctx.from!.id, 'sell everything', run); @@ -711,13 +732,33 @@ export async function promptFund(ctx: Context, mode: FundMode, sol: number): Pro lines.push(`${plan.skipped.length} wallet${plan.skipped.length === 1 ? '' : 's'} skipped — already funded.`); } - const run = async (ctx: Context) => { + const run = async (ctx: Context) => withExecution(async () => { await render(ctx, `⬇️ Funding ${plan.transfers.length} wallets…`); try { + // A confirmation may wait while trades change both sides of the plan. + // Recompute under the operation gate, shrinking a top-up if funds arrived + // and refusing a larger transfer than the operator saw on screen. + const [balances, source] = await Promise.all([ + fundingBalances(plan.transfers.map((t) => t.address)), + getSolBalance(main.address), + ]); + const currentPlan = planFunding({ + targets: plan.transfers.map((t) => ({ id: t.walletId, address: t.address, label: t.label })), + balances, + mode, + sol, + sourceLamports: source.lamports, + priorityFeeSol: settings.priorityFeeSol, + reserveSol: settings.sweepReserveSol, + }); + if (currentPlan.transfers.some((t) => + t.lamports > (plan.transfers.find((old) => old.walletId === t.walletId)?.lamports ?? 0n))) { + throw new Error('Wallet balances changed and this needs a larger transfer. Start funding again.'); + } const summary = await executeFunding( main, - plan, + currentPlan, settings.priorityFeeSol, throttledProgress(ctx, 'Funding wallets'), ); @@ -738,7 +779,7 @@ export async function promptFund(ctx: Context, mode: FundMode, sol: number): Pro } catch (err) { await render(ctx, `❌ ${h(errMessage(err))}`, backButton('fund_menu')); } - }; + }); if (!config.safety.requireConfirmation) { await run(ctx); @@ -767,7 +808,7 @@ export async function promptSweepSol(ctx: Context): Promise { return; } - const run = async (ctx: Context) => { + const run = async (ctx: Context) => withExecution(async () => { await render(ctx, `💸 Sweeping ${wallets.length} wallets…`); try { const summary = await batchSweepSol(wallets, main.address, throttledProgress(ctx, 'Sweeping SOL')); @@ -788,7 +829,7 @@ export async function promptSweepSol(ctx: Context): Promise { } catch (err) { await render(ctx, `❌ ${h(errMessage(err))}`, backButton()); } - }; + }); const id = stageConfirmation(ctx.from!.id, 'sweep SOL', run); @@ -823,6 +864,10 @@ export async function promptSweepToken(ctx: Context): Promise { } export async function executeSweepToken(ctx: Context, mint: string): Promise { + return withExecution(() => executeSweepTokenLocked(ctx, mint)); +} + +async function executeSweepTokenLocked(ctx: Context, mint: string): Promise { const main = mainWallet(); if (!main) { await ctx.reply('Set a main Solana wallet first.'); @@ -1420,7 +1465,6 @@ const TOP10_STEPS = [10, 20, 30, 40, 60, 100]; * long positions are held rather than how long the contract runs — a copy * closed in minutes is not reached by a ninety-day cliff. */ -const LOCK_STEPS = [30, 90, 365]; const DEV_STEPS = [0, 1, 2, 5, 10, 100]; const LIQ_STEPS = [0, 1_000, 3_000, 10_000, 25_000]; /* @@ -1472,13 +1516,11 @@ export async function showCopySafety(ctx: Context): Promise { '', 'Only copy trading is gated. Buying by hand shows you the same warnings and lets you decide.', '', - 'Anything unreadable counts as a failure — a holder query the RPC refused means concentration is unknown, not zero.', + 'Unreadable on-chain mint, holder or developer-balance data refuses a copy. Optional index checks can have no answer; absence does not certify safety.', ].join('\n'), new InlineKeyboard() .text(`👥 Top 10 max ${limits.maxTop10Pct}%`, 'safety_top10').primary() .row() - .text(`🔐 Ignore supply locked ${formatHorizon(limits.lockHorizonDays)}+`, 'safety_lock').primary() - .row() .text(`👤 Dev max ${limits.maxDevPct}%`, 'safety_dev').primary() .row() .text(`🔒 Authorities: ${limits.requireRevokedAuthorities ? 'must be revoked' : 'not checked'}`, 'safety_auth') @@ -1560,7 +1602,6 @@ export async function cycleSafety(ctx: Context, which: string): Promise { else if (which === 'insider') limits.maxInsiderPct = cycleStep(INSIDER_STEPS, limits.maxInsiderPct); else if (which === 'factory') limits.maxDevMints = cycleStep(DEVMINT_STEPS, limits.maxDevMints); else if (which === 'traders') limits.minTraders5m = cycleStep(TRADERS_STEPS, limits.minTraders5m); - else if (which === 'lock') limits.lockHorizonDays = cycleStep(LOCK_STEPS, limits.lockHorizonDays); db.updateSettings({ copySafety: limits }); await showCopySafety(ctx); diff --git a/src/bot/handlers/wallets.ts b/src/bot/handlers/wallets.ts index 611a440..e253e39 100644 --- a/src/bot/handlers/wallets.ts +++ b/src/bot/handlers/wallets.ts @@ -19,7 +19,8 @@ import { } from '../../store/wallets.js'; import { getSolBalance } from '../../chains/solana.js'; import { errMessage, fmtAmount, shortAddr } from '../../util.js'; -import { setPending, shortWalletId, walletFromShortId, stageConfirmation } from '../session.js'; +import { setPending, shortWalletId, walletFromShortId, stageConfirmation, clearAllSessions } from '../session.js'; +import { withExecutionMaintenance } from '../../services/execution.js'; import { renderWalletList, walletsKeyboard, @@ -264,7 +265,10 @@ export async function promptRemove(ctx: Context, walletId: string): Promise { - removeWallet(walletId); + await withExecutionMaintenance(async () => { + removeWallet(walletId); + clearAllSessions(); + }); await render(confirmCtx, `🗑 Removed ${h(w.label)}.`, new InlineKeyboard().text('👛 Wallets', 'wallets')); }); diff --git a/src/bot/session.ts b/src/bot/session.ts index 0e454ec..f7b59fa 100644 --- a/src/bot/session.ts +++ b/src/bot/session.ts @@ -1,5 +1,7 @@ import crypto from 'node:crypto'; import type { Context } from 'grammy'; +import { executionEpoch } from '../services/execution.js'; +import { db } from '../store/db.js'; /** * In-memory conversational state. Single-operator bot, so a plain Map keyed by @@ -46,6 +48,8 @@ export interface SessionState { export interface ConfirmAction { label: string; createdAt: number; + epoch: number; + accountState: string; /** * Receives the context of the tap that confirmed it, not the one that staged * it. A confirmation staged from a typed message has no message to edit, so an @@ -57,6 +61,20 @@ export interface ConfirmAction { const sessions = new Map(); +/** A confirmation must still describe the settings and wallet set on screen. */ +function confirmationState(): string { + return JSON.stringify({ + settings: db.settings(), + wallets: db.raw().wallets.map((w) => ({ + id: w.id, + address: w.address, + isMain: w.isMain, + disabled: w.disabled, + groups: w.groups, + })), + }); +} + /** Never overwrite the action or address referenced by an existing button. */ function freshId(existing: ReadonlyMap, bytes: number): string { let id: string; @@ -146,7 +164,9 @@ export function stageConfirmation( if (action.createdAt < cutoff) s.confirmations.delete(key); } - s.confirmations.set(id, { label, createdAt: Date.now(), run }); + s.confirmations.set(id, { + label, createdAt: Date.now(), epoch: executionEpoch(), accountState: confirmationState(), run, + }); return id; } @@ -156,7 +176,7 @@ export function takeConfirmation(userId: number, id: string): ConfirmAction | un if (action) s.confirmations.delete(id); // Check at the moment of use as well as when staging another action: the // operator may return to an old button without creating a newer prompt. - if (!action || Date.now() - action.createdAt > CONFIRMATION_TTL_MS) return undefined; + if (!action || action.epoch !== executionEpoch() || action.accountState !== confirmationState() || Date.now() - action.createdAt > CONFIRMATION_TTL_MS) return undefined; return action; } @@ -218,3 +238,12 @@ export function walletFromShortId(id: string): string | undefined { export function clearSession(userId: number): void { sessions.delete(userId); } + +/** A factory reset invalidates every operator's old prompts and buttons. */ +export function clearAllSessions(): void { + sessions.clear(); + tokenIds.clear(); + idsByToken.clear(); + walletIds.clear(); + idsByWallet.clear(); +} diff --git a/src/bot/ui.ts b/src/bot/ui.ts index e908797..e1c0871 100644 --- a/src/bot/ui.ts +++ b/src/bot/ui.ts @@ -14,7 +14,7 @@ import type { Settings, ValueMark, CopyDecision } from '../store/db.js'; import { formatAccountPnl, markAgo, formatValueChange, type AccountPnl } from '../services/pnl.js'; import type { Portfolio } from '../services/portfolio.js'; import type { TokenInfo } from '../services/tokeninfo.js'; -import { assessToken, DEFAULT_SAFETY, formatAge, formatHorizon, type SafetyLimits } from '../services/safety.js'; +import { assessToken, DEFAULT_SAFETY, formatAge, type SafetyLimits } from '../services/safety.js'; import type { BatchSummary, WalletRecord } from '../types.js'; /** Telegram HTML mode needs exactly these three escaped. */ @@ -414,8 +414,15 @@ export function renderTokenCard(info: TokenInfo, limits = DEFAULT_SAFETY): strin if (info.holderCount !== undefined) { lines.push(` 👥 Holders ${fmtCount(info.holderCount)}`); } - lines.push(` 🏆 Top 10 ${judged(info.top10Pct, limits.maxTop10Pct)}`); - lines.push(` 🧑‍💻 Dev holds ${judged(info.creatorHoldsPct, limits.maxDevPct, '%', 2)}`); + lines.push(` 🏆 Top 10 ${judged(info.holdersUnavailable ? undefined : info.top10Pct, limits.maxTop10Pct)}`); + if (!info.holdersUnavailable && info.top10PctUpperBound !== undefined && info.top10PctUpperBound > (info.top10Pct ?? 0)) { + lines.push(` 🔎 Sample bound up to ${info.top10PctUpperBound.toFixed(1)}%; unsampled holdings included in safety.`); + } + lines.push(` 🧑‍💻 Dev holds ${judged(info.creatorBalanceUnavailable ? undefined : info.creatorHoldsPct, limits.maxDevPct, '%', 2)}`); + if (info.lockedSupply && info.lockedSupply.length > 0) { + const outstanding = info.lockedSupply.reduce((sum, stream) => sum + stream.pct, 0); + lines.push(` ⏳ Vesting ${outstanding.toFixed(1)}% remains in streams; potentially claimable. No concentration discount.`); + } /* * The line no reading of the token itself can produce. @@ -510,7 +517,7 @@ export function renderTokenCard(info: TokenInfo, limits = DEFAULT_SAFETY): strin // the launch wallet's remaining stake — the clearest rug signal pump.fun gives if (info.creator) { const stake = - info.creatorHoldsPct === undefined + info.creatorBalanceUnavailable || info.creatorHoldsPct === undefined ? '❓ unknown' : info.creatorHoldsPct === 0 ? '✅ sold out / holds none' @@ -520,7 +527,7 @@ export function renderTokenCard(info: TokenInfo, limits = DEFAULT_SAFETY): strin } // holder distribution - if (info.holdersUnavailable && info.top10Pct === undefined) { + if (info.holdersUnavailable) { lines.push(''); lines.push('👥 Top holders'); lines.push('Unavailable — RPC rejected the query. Use a private endpoint.'); @@ -789,7 +796,7 @@ export function renderSettings(s: Settings, walletCount: number): string { '', 'Copy trade safety', ` Top 10 max ${s.copySafety.maxTop10Pct}%`, - ` Locked supply ignored past ${formatHorizon(s.copySafety.lockHorizonDays)}`, + ' Vesting no concentration discount without verified proof', ` Dev max ${s.copySafety.maxDevPct}%`, ` Max age ${s.copySafety.maxAgeHours > 0 ? formatAge(s.copySafety.maxAgeHours) : 'any'}`, ` 1h volume ${s.copySafety.minVolume1hUsd > 0 ? `min $${s.copySafety.minVolume1hUsd.toLocaleString('en-US')}` : 'any'}`, diff --git a/src/chains/solana.ts b/src/chains/solana.ts index 77624a9..889c420 100644 --- a/src/chains/solana.ts +++ b/src/chains/solana.ts @@ -16,12 +16,17 @@ import { createAssociatedTokenAccountIdempotentInstruction, createTransferCheckedInstruction, createCloseAccountInstruction, + unpackMint, + getTransferFeeAmount, + unpackAccount, + createHarvestWithheldTokensToMintInstruction, } from '@solana/spl-token'; import { config } from '../config.js'; import bs58 from 'bs58'; import { retry } from '../util.js'; import type { TokenBalance } from '../types.js'; import { TransactionRejectedError, TransactionSubmissionUnknownError } from '../trade/errors.js'; +import { assertExecutionCurrent } from '../services/execution.js'; export const LAMPORTS = LAMPORTS_PER_SOL; export const WSOL_MINT = 'So11111111111111111111111111111111111111112'; @@ -117,9 +122,7 @@ export async function getSplBalances(address: string): Promise { const [classic, token22] = await Promise.all([ retry(() => rpc().getParsedTokenAccountsByOwner(owner, { programId: TOKEN_PROGRAM_ID })), - retry(() => rpc().getParsedTokenAccountsByOwner(owner, { programId: TOKEN_2022_PROGRAM_ID })).catch( - () => ({ value: [] as never[] }), - ), + retry(() => rpc().getParsedTokenAccountsByOwner(owner, { programId: TOKEN_2022_PROGRAM_ID })), ]); const holdings: SplHolding[] = []; @@ -130,12 +133,15 @@ export async function getSplBalances(address: string): Promise { ]) { for (const acc of value) { const info = (acc.account.data as never as { parsed: { info: ParsedTokenInfo } }).parsed.info; + assertPublicTokenBalance(info); const raw = BigInt(info.tokenAmount.amount); if (raw === 0n) continue; holdings.push({ mint: info.mint, symbol: info.mint.slice(0, 4), - amount: info.tokenAmount.uiAmount ?? 0, + // UI floats may be null even though the raw balance is non-zero. + // Keep accounting in unscaled token units, as used by trade quantities. + amount: Number(raw) / 10 ** info.tokenAmount.decimals, decimals: info.tokenAmount.decimals, rawAmount: raw, tokenAccount: acc.pubkey.toBase58(), @@ -150,14 +156,45 @@ export async function getSplBalances(address: string): Promise { interface ParsedTokenInfo { mint: string; tokenAmount: { amount: string; decimals: number; uiAmount: number | null }; + extensions?: Array<{ extension: string }>; +} + +function assertPublicTokenBalance(info: ParsedTokenInfo): void { + const amount = info.tokenAmount?.amount; + const decimals = info.tokenAmount?.decimals; + if (typeof amount !== 'string' || !/^\d{1,20}$/.test(amount) || BigInt(amount) > (1n << 64n) - 1n || + !Number.isInteger(decimals) || decimals < 0 || decimals > 255) { + throw new Error('Token balance response contains an invalid raw amount or decimals.'); + } + if (info.extensions?.some((ext) => ext.extension === 'confidentialTransferAccount')) { + throw new Error('Confidential token balances cannot be valued by public RPC reads.'); + } } -/** Balance of one specific mint. Returns zeros when the wallet holds none. */ +/** First account of one mint, retained for callers needing an account address. */ export async function getTokenBalance(address: string, mint: string): Promise { const all = await getSplBalances(address); return all.find((h) => h.mint === mint) ?? null; } +/** Every account for a mint; a wallet can own several, including non-ATAs. */ +export async function getTokenAccounts(address: string, mint: string): Promise { + return (await getSplBalances(address)).filter((h) => h.mint === mint); +} + +/** Decimals are immutable mint metadata, never a six-decimal assumption. */ +export async function getMintDecimals(mint: string): Promise { + const key = new PublicKey(mint); + const info = await retry(() => rpc().getAccountInfo(key), { attempts: 2 }); + if (!info) throw new Error('Mint account is unavailable.'); + if (!info.owner.equals(TOKEN_PROGRAM_ID) && !info.owner.equals(TOKEN_2022_PROGRAM_ID)) { + throw new Error('Account is not an SPL token mint.'); + } + const decoded = unpackMint(key, info, info.owner); + if (!decoded.isInitialized) throw new Error('Mint account is not initialized.'); + return decoded.decimals; +} + /** * Raw amount held in an SPL token account, read straight from its data buffer. * Layout: mint(32) || owner(32) || amount(u64 LE). Token-2022 keeps the same @@ -171,49 +208,32 @@ export function parseTokenAccountAmount(data: Uint8Array): bigint { /** * How much of one mint each of many wallets holds. * - * Deriving the associated token address and reading those accounts directly - * costs one RPC round trip per 100 wallets, where - * `getParsedTokenAccountsByOwner` costs one per wallet. That difference is the - * gap between a token card that renders instantly and one that takes ten - * seconds — or gets the operator rate limited mid-batch. - * - * The trade-off: this sees *associated* token accounts only. Every position - * these wallets can acquire through this bot lands in one — PumpPortal, Jupiter - * and the token sweep all use the associated account — but a balance parked in a - * non-associated account by some other tool reads here as zero. Use - * `getSplBalances` when an exhaustive answer matters more than the round trips. + * This deliberately reads all token accounts, rather than only each ATA. + * Basis resets and full exits cannot treat a non-ATA holding as an empty + * position. The mint filter covers either token program in one read per owner; + * bounded concurrency prevents a large wallet set from flooding the RPC. */ export async function getMintBalances(addresses: string[], mint: string): Promise> { const out = new Map(); if (addresses.length === 0) return out; const mintKey = new PublicKey(mint); - - // A mint belongs to exactly one token program, so classic is checked first and - // Token-2022 only when that turned up nothing at all. - for (const program of [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) { - const atas = addresses.map((a) => - getAssociatedTokenAddressSync(mintKey, new PublicKey(a), true, program), - ); - - let found = false; - for (let i = 0; i < atas.length; i += 100) { - const slice = atas.slice(i, i + 100); - const infos = await retry(() => rpc().getMultipleAccountsInfo(slice), { attempts: 2 }); - - slice.forEach((_, j) => { - const info = infos[j]; - if (!info) return; - const amount = parseTokenAccountAmount(info.data); - if (amount > 0n) { - out.set(addresses[i + j]!, amount); - found = true; - } - }); + const owners = [...new Set(addresses)]; + let next = 0; + await Promise.all(Array.from({ length: Math.min(5, owners.length) }, async () => { + while (next < owners.length) { + const address = owners[next++]!; + const result = await retry(() => rpc().getParsedTokenAccountsByOwner(new PublicKey(address), { mint: mintKey }), { attempts: 2 }); + let total = 0n; + for (const account of result.value) { + const info = (account.account.data as never as { parsed: { info: ParsedTokenInfo } }).parsed.info; + assertPublicTokenBalance(info); + if (info.mint !== mint) throw new Error('Token balance response contains a different mint.'); + total += BigInt(info.tokenAmount.amount); + } + if (total > 0n) out.set(address, total); } - - if (found) return out; - } + })); return out; } @@ -299,6 +319,7 @@ export async function sendAndConfirm( // provider accepts it and then loses the response. const bytes = tx.serialize(); const signature = bs58.encode(tx.signatures[0]!); + assertExecutionCurrent(); try { await sendRpc().sendRawTransaction(bytes, { skipPreflight: opts.skipPreflight ?? true, @@ -484,6 +505,14 @@ export async function sendSplToken( // reclaim the ~0.002 SOL rent sitting in the now-empty token account if (closeAccountAfter) { + if (program.equals(TOKEN_2022_PROGRAM_ID)) { + const info = await retry(() => rpc().getAccountInfo(source), { attempts: 2 }); + if (!info) throw new Error('Source token account is unavailable.'); + const account = unpackAccount(source, info, program); + if ((getTransferFeeAmount(account)?.withheldAmount ?? 0n) > 0n) { + ixs.push(createHarvestWithheldTokensToMintInstruction(mintKey, [source], program)); + } + } ixs.push(createCloseAccountInstruction(source, from.publicKey, from.publicKey, [], program)); } diff --git a/src/config.ts b/src/config.ts index d22156e..24fb15c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,17 +11,33 @@ function opt(name: string, fallback = ''): string { return process.env[name]?.trim() || fallback; } -function num(name: string, fallback: number): number { +function num(name: string, fallback: number, min: number, max: number, integer = false): number { const raw = process.env[name]?.trim(); if (!raw) return fallback; + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(raw)) { + throw new Error(`${name} must be a finite decimal number.`); + } const n = Number(raw); - return Number.isFinite(n) ? n : fallback; + if (!Number.isFinite(n) || n < min || n > max || (integer && !Number.isSafeInteger(n))) { + throw new Error(`${name} must be ${integer ? 'an integer' : 'a finite number'} between ${min} and ${max}.`); + } + return n; +} + +function solAmount(name: string, fallback: number, positive = false): number { + const value = num(name, fallback, positive ? 1e-9 : 0, Number.MAX_SAFE_INTEGER / 1e9); + if (!Number.isSafeInteger(Math.floor(value * 1e9))) { + throw new Error(`${name} must convert to a safe integer number of lamports.`); + } + return value; } function bool(name: string, fallback: boolean): boolean { const raw = process.env[name]?.trim().toLowerCase(); if (!raw) return fallback; - return raw === 'true' || raw === '1' || raw === 'yes'; + if (['true', '1', 'yes'].includes(raw)) return true; + if (['false', '0', 'no'].includes(raw)) return false; + throw new Error(`${name} must be true or false.`); } export type ExecutionMode = 'bundle' | 'parallel'; @@ -29,13 +45,39 @@ export type ExecutionMode = 'bundle' | 'parallel'; /** Solana's own endpoint: fine for a health check, unusable for real reads. */ const PUBLIC_RPC = 'https://api.mainnet-beta.solana.com'; +function executionMode(): ExecutionMode { + const mode = opt('DEFAULT_EXECUTION_MODE', 'parallel'); + if (mode !== 'parallel' && mode !== 'bundle') { + throw new Error('DEFAULT_EXECUTION_MODE must be parallel or bundle.'); + } + return mode; +} + +function ownerIds(): number[] { + const ids = req('OWNER_IDS').split(',').map((s) => s.trim()); + if (ids.some((id) => !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)) || Number(id) <= 0)) { + throw new Error('OWNER_IDS must contain only positive integer Telegram user IDs, separated by commas.'); + } + return [...new Set(ids.map(Number))]; +} + +function jupiterBaseUrl(): string { + const raw = opt('JUPITER_API_BASE_URL', 'https://api.jup.ag'); + let url: URL; + try { url = new URL(raw); } catch { throw new Error('JUPITER_API_BASE_URL must be an HTTPS origin.'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') { + throw new Error('JUPITER_API_BASE_URL must be an HTTPS origin without credentials, a path, query, or fragment.'); + } + return url.origin; +} + +const jupiterApiKey = opt('JUPITER_API_KEY'); +if (/[\r\n]/.test(jupiterApiKey)) throw new Error('JUPITER_API_KEY must not contain line breaks.'); + export const config = { botToken: req('BOT_TOKEN'), - ownerIds: req('OWNER_IDS') - .split(',') - .map((s) => Number(s.trim())) - .filter((n) => Number.isInteger(n) && n > 0), + ownerIds: ownerIds(), solana: { rpcUrl: opt('SOLANA_RPC_URL', PUBLIC_RPC), @@ -57,23 +99,31 @@ export const config = { * default: with no passphrase there is nothing to type to get it back, so * locking would just break the bot until it restarts. */ - autolockMinutes: num('VAULT_AUTOLOCK_MINUTES', 0), + autolockMinutes: num('VAULT_AUTOLOCK_MINUTES', 0, 0, 2_147_483_647 / 60_000), }, trading: { - slippagePercent: num('DEFAULT_SLIPPAGE_PERCENT', 15), - priorityFeeSol: num('DEFAULT_PRIORITY_FEE_SOL', 0.00005), - executionMode: (opt('DEFAULT_EXECUTION_MODE', 'parallel') as ExecutionMode), - concurrency: Math.max(1, num('EXECUTION_CONCURRENCY', 5)), - jitoTipSol: num('JITO_TIP_SOL', 0.0001), + slippagePercent: num('DEFAULT_SLIPPAGE_PERCENT', 15, 0, 99.99), + priorityFeeSol: solAmount('DEFAULT_PRIORITY_FEE_SOL', 0.00005), + executionMode: executionMode(), + concurrency: num('EXECUTION_CONCURRENCY', 5, 1, 1_000, true), + jitoTipSol: solAmount('JITO_TIP_SOL', 0.0001), }, safety: { - maxBuySolPerWallet: num('MAX_BUY_SOL_PER_WALLET', 5), + maxBuySolPerWallet: solAmount('MAX_BUY_SOL_PER_WALLET', 5, true), requireConfirmation: bool('REQUIRE_CONFIRMATION', true), }, dataDir: path.resolve(process.cwd(), opt('DATA_DIR', './data')), + + jupiter: { + baseUrl: jupiterBaseUrl(), + apiKey: jupiterApiKey, + // The new gateway permits 0.5 requests/sec without a key, or 1/sec on + // the free keyed plan. Operators on other plans can set their interval. + requestIntervalMs: num('JUPITER_REQUEST_INTERVAL_MS', jupiterApiKey ? 1_000 : 2_000, 0, 60_000, true), + }, } as const; if (config.ownerIds.length === 0) { @@ -84,9 +134,10 @@ if (config.ownerIds.length === 0) { export const endpoints = { pumpPortalTradeLocal: 'https://pumpportal.fun/api/trade-local', jitoBundles: 'https://mainnet.block-engine.jito.wtf/api/v1/bundles', - jupiterQuote: 'https://lite-api.jup.ag/swap/v1/quote', - jupiterSwap: 'https://lite-api.jup.ag/swap/v1/swap', - jupiterPrice: 'https://lite-api.jup.ag/price/v3', + jupiterQuote: `${config.jupiter.baseUrl}/swap/v1/quote`, + jupiterSwap: `${config.jupiter.baseUrl}/swap/v1/swap`, + jupiterPrice: `${config.jupiter.baseUrl}/price/v3`, + jupiterTokens: `${config.jupiter.baseUrl}/tokens/v2/search`, dexscreenerTokens: 'https://api.dexscreener.com/latest/dex/tokens', /** * Every pair for one token on one chain. diff --git a/src/services/copytrade.ts b/src/services/copytrade.ts index 8193fc1..a41b469 100644 --- a/src/services/copytrade.ts +++ b/src/services/copytrade.ts @@ -1,7 +1,7 @@ import { PublicKey } from '@solana/web3.js'; -import { rpc, WSOL_MINT, getMintBalances, LAMPORTS } from '../chains/solana.js'; +import { rpc, WSOL_MINT, getMintBalances, getMintDecimals, LAMPORTS } from '../chains/solana.js'; import { db, type CopyTarget, type CopyExitMode } from '../store/db.js'; -import { selectWallets } from '../store/wallets.js'; +import { selectWallets, allWallets } from '../store/wallets.js'; import { batchPumpTrade, measureTokensGained, measureTokensSold } from '../trade/engine.js'; import { retry, errMessage, fmtAmount, escapeHtml as h } from '../util.js'; import { config } from '../config.js'; @@ -10,6 +10,7 @@ import { getTokenInfo, type TokenInfo } from './tokeninfo.js'; import { log } from '../logger.js'; import { newRuleId, type Notifier } from './watcher.js'; import { exitResult, formatExit } from './pnl.js'; +import { withExecution, executionEpoch, assertExecutionEpoch, assertExecutionCurrent } from './execution.js'; /** * Copy trading: mirror another wallet's entries and exits. @@ -37,7 +38,20 @@ interface ParsedAccount { interface ParsedBalance { mint: string; owner?: string; - uiTokenAmount: { uiAmount: number | null }; + uiTokenAmount: { amount?: string; decimals?: number; uiAmount: number | null }; +} + +function balanceAmount(balance: ParsedBalance): number | undefined { + const { amount, decimals, uiAmount } = balance.uiTokenAmount; + // Scaled UI display amounts can change without a token transfer. Raw units + // and the mint's decimals are the same units used by the position ledger. + if (amount !== undefined || decimals !== undefined) { + if (typeof amount !== 'string' || !/^\d+$/.test(amount) || typeof decimals !== 'number' || + !Number.isInteger(decimals) || decimals < 0 || decimals > 255) return undefined; + const value = Number(amount) / 10 ** decimals; + return Number.isFinite(value) && value >= 0 ? value : undefined; + } + return typeof uiAmount === 'number' && Number.isFinite(uiAmount) && uiAmount >= 0 ? uiAmount : undefined; } /** @@ -53,20 +67,25 @@ export function detectTokenMoves( ): TokenMove[] { const before = new Map(); const after = new Map(); + const unreadable = new Set(); for (const b of pre) { if (b.owner !== owner) continue; - before.set(b.mint, (before.get(b.mint) ?? 0) + (b.uiTokenAmount.uiAmount ?? 0)); + const amount = balanceAmount(b); + if (amount === undefined) unreadable.add(b.mint); + else before.set(b.mint, (before.get(b.mint) ?? 0) + amount); } for (const b of post) { if (b.owner !== owner) continue; - after.set(b.mint, (after.get(b.mint) ?? 0) + (b.uiTokenAmount.uiAmount ?? 0)); + const amount = balanceAmount(b); + if (amount === undefined) unreadable.add(b.mint); + else after.set(b.mint, (after.get(b.mint) ?? 0) + amount); } const moves: TokenMove[] = []; for (const mint of new Set([...before.keys(), ...after.keys()])) { // wrapped SOL moves on nearly every swap and means nothing on its own - if (mint === WSOL_MINT) continue; + if (mint === WSOL_MINT || unreadable.has(mint)) continue; const start = before.get(mint) ?? 0; const end = after.get(mint) ?? 0; @@ -299,12 +318,45 @@ export async function pollCopyTargets(notify: Notifier): Promise { * * The same transaction can arrive twice: once pushed down the socket and again * when the reconciling poll sweeps up. Copying it twice would buy twice, so - * every path claims a signature here before it spends anything. Bounded, - * because the only ones worth remembering are the recent ones — anything older - * is behind `lastSignature` and will not be offered again. + * Durable receipts on each target govern execution. This bounded in-memory + * set supports diagnostics; it is never the only record of a copied event. */ const processed = new Set(); const PROCESSED_MAX = 600; +const receiptReads = new Map>(); +const targetReads = new Map>(); + +function currentTarget(target: CopyTarget): CopyTarget | undefined { + const current = db.copyTargets().find((t) => t.id === target.id); + return current?.enabled ? current : undefined; +} + +function assertCopyCurrent(target: CopyTarget): void { + assertExecutionCurrent(); + if (!currentTarget(target)) throw new Error('Copy target was disabled or removed before execution.'); +} + +function copyIntent(target: CopyTarget): string { + return JSON.stringify({ + address: target.address, buySol: target.buySol, sizeMode: target.sizeMode, + sizePercent: target.sizePercent, entryMode: target.entryMode, maxEntries: target.maxEntries, + exitMode: target.exitMode, takeProfitPct: target.takeProfitPct, stopLossPct: target.stopLossPct, + takeProfitSellPct: target.takeProfitSellPct, + }); +} + +function receiptKey(target: CopyTarget, signature: string): string { + return JSON.stringify([target.id, signature]); +} + +/** Persist before money can move; a crash must never replay an uncertain copy. */ +function rememberReceipt(target: CopyTarget, signature: string): void { + if (target.handledSignatures?.includes(signature)) return; + db.updateCopyTarget(target.id, { + handledSignatures: [...(target.handledSignatures ?? []), signature].slice(-PROCESSED_MAX), + }); + claimSignature(receiptKey(target, signature)); +} /** Test seam: forget what has been seen, as a fresh process would. */ export function resetProcessed(): void { @@ -423,36 +475,76 @@ async function handleSignature( signature: string, notify: Notifier, source: 'socket' | 'poll' = 'socket', -): Promise { - if (!claimSignature(signature)) return; - claims[source]++; +): Promise { + const key = receiptKey(target, signature); + const pending = receiptReads.get(key); + if (pending) return pending; + const epoch = executionEpoch(); + const previous = targetReads.get(target.id) ?? Promise.resolve(); + const work = previous.catch(() => {}).then(() => readSignature(target, signature, notify, source, epoch)); + const tail = work.then(() => {}, () => {}); + targetReads.set(target.id, tail); + receiptReads.set(key, work); + try { + return await work; + } finally { + if (receiptReads.get(key) === work) receiptReads.delete(key); + if (targetReads.get(target.id) === tail) targetReads.delete(target.id); + } +} - const [tx] = await retry( - () => rpc().getParsedTransactions([signature], { maxSupportedTransactionVersion: 0 }), - { attempts: 2 }, - ); - if (!tx?.meta || tx.meta.err) return; +async function readSignature( + target: CopyTarget, + signature: string, + notify: Notifier, + source: 'socket' | 'poll', + epoch: number, +): Promise { + assertExecutionEpoch(epoch); + const initial = currentTarget(target); + if (!initial) return false; + if (initial.handledSignatures?.includes(signature)) return true; + + let tx; + try { + tx = await retry(async () => { + const [parsed] = await rpc().getParsedTransactions([signature], { maxSupportedTransactionVersion: 0 }); + // A confirmed log can precede this RPC's readable receipt. Null is a + // retryable read, never evidence that the transaction was handled. + if (!parsed?.meta) throw new Error('Copy transaction receipt is not yet readable.'); + return parsed; + }, { attempts: 2 }); + } catch (err) { + log.warn(`Could not read ${target.label}'s transaction; reconciliation will retry: ${errMessage(err)}`); + return false; + } + assertExecutionEpoch(epoch); + const current = currentTarget(target); + if (!current) return false; + // Persist only after the receipt is readable, but before any submission. + // This deliberately guarantees at most one attempt after a process crash. + rememberReceipt(current, signature); + claims[source]++; + if (tx.meta!.err) return true; const moves = detectTokenMoves( - (tx.meta.preTokenBalances ?? []) as ParsedBalance[], - (tx.meta.postTokenBalances ?? []) as ParsedBalance[], - target.address, + (tx.meta!.preTokenBalances ?? []) as ParsedBalance[], + (tx.meta!.postTokenBalances ?? []) as ParsedBalance[], + current.address, ); - if (moves.length === 0) return; + if (moves.length === 0) return true; // what the whole transaction cost them, used to size a proportional copy const theirSol = solSpent( tx.transaction.message.accountKeys as ParsedAccount[], - tx.meta.preBalances ?? [], - tx.meta.postBalances ?? [], - target.address, + tx.meta!.preBalances ?? [], + tx.meta!.postBalances ?? [], + current.address, ); - // re-read: the stored target may have been edited since this was queued - const current = db.copyTargets().find((t) => t.id === target.id); - if (!current || !current.enabled) return; - for (const move of moves) { + assertExecutionEpoch(epoch); + if (!currentTarget(current)) return true; if (move.delta > 0) { /* * A token arriving is not a purchase. Someone dusting a followed wallet @@ -472,13 +564,20 @@ async function handleSignature( await mirrorSell(current, move, notify); } } + return true; } +const POLL_PAGE_SIZE = 100; +const POLL_MAX_PAGES = 5; + async function pollTarget(target: CopyTarget, notify: Notifier): Promise { + const epoch = executionEpoch(); const signatures = await retry( - () => rpc().getSignaturesForAddress(new PublicKey(target.address), { limit: 10 }), + () => rpc().getSignaturesForAddress(new PublicKey(target.address), { limit: POLL_PAGE_SIZE }), { attempts: 2 }, ); + assertExecutionEpoch(epoch); + if (!currentTarget(target)) return; if (signatures.length === 0) return; const newest = signatures[0]!.signature; @@ -489,24 +588,66 @@ async function pollTarget(target: CopyTarget, notify: Notifier): Promise { * fistful of positions the operator never chose, some of them hours stale. */ if (!target.lastSignature) { - db.updateCopyTarget(target.id, { lastSignature: newest }); + db.updateCopyTarget(target.id, { + lastSignature: newest, + handledSignatures: [...new Set([...(target.handledSignatures ?? []), ...signatures.map((s) => s.signature)])] + .slice(-PROCESSED_MAX), + }); // everything already on screen is history, not a signal to act on - for (const s of signatures) claimSignature(s.signature); + for (const s of signatures) claimSignature(receiptKey(target, s.signature)); return; } - // oldest first, so their sequence is followed in the order it happened - const fresh: string[] = []; - for (const s of signatures) { - if (s.signature === target.lastSignature) break; - if (!s.err) fresh.push(s.signature); + const cursor = target.lastSignature; + const fresh: typeof signatures = []; + let page = signatures; + let foundCursor = false; + for (let pages = 1; pages <= POLL_MAX_PAGES; pages++) { + for (const s of page) { + if (s.signature === cursor) { + foundCursor = true; + break; + } + fresh.push(s); + } + if (foundCursor || page.length < POLL_PAGE_SIZE || pages === POLL_MAX_PAGES) break; + const before = page.at(-1)!.signature; + page = await retry( + () => rpc().getSignaturesForAddress(new PublicKey(target.address), { limit: POLL_PAGE_SIZE, before }), + { attempts: 2 }, + ); + assertExecutionEpoch(epoch); + if (!currentTarget(target)) return; } - if (fresh.length === 0) return; - fresh.reverse(); - db.updateCopyTarget(target.id, { lastSignature: newest }); + if (!foundCursor) { + // A bounded history with an unknown gap cannot safely replay entries or + // proportional exits. Preserve the cursor and require a deliberate resume. + db.updateCopyTarget(target.id, { enabled: false }); + dropQueued(target.address); + await unsubscribe(target.address); + log.warn(`Paused ${target.label}: its previous signature was not found within ${fresh.length} receipts.`); + await notify( + `⚠️ Copy trading paused for ${h(target.label)}\n\n` + + `The previous checkpoint was missing from the last ${fresh.length} transactions. ` + + 'There may be a gap in the history, so no trades from this backlog were copied. ' + + 'Review this trader before following again.', + ).catch(() => {}); + return; + } - for (const signature of fresh) await handleSignature(target, signature, notify, 'poll'); + // Advance only across resolved receipts, including transactions that failed + // on chain. An unreadable receipt blocks this cursor until a later sweep. + for (const s of fresh.reverse()) { + assertExecutionEpoch(epoch); + if (!currentTarget(target)) return; + const signature = s.signature; + if (s.err) rememberReceipt(target, signature); + else if (!(await handleSignature(target, signature, notify, 'poll'))) return; + assertExecutionEpoch(epoch); + if (!currentTarget(target)) return; + db.updateCopyTarget(target.id, { lastSignature: signature }); + } } // ── live subscriptions ──────────────────────────────────────────────────────── @@ -555,6 +696,12 @@ const FLOOD_LIMIT = 60; const floodCounts = new Map(); let draining = false; +function dropQueued(address: string): void { + for (let i = queue.length - 1; i >= 0; i--) { + if (queue[i]!.target.address === address) queue.splice(i, 1); + } +} + /** * Make room by dropping the stalest trade from the busiest wallet. * @@ -589,7 +736,10 @@ function enqueue(item: Queued): void { floodCounts.set(item.target.address, dropped); if (dropped === FLOOD_LIMIT) { - log.warn(`${item.target.label} is too busy to follow — dropping its subscription.`); + log.warn(`${item.target.label} is too busy to follow — disabling the target.`); + db.updateCopyTarget(item.target.id, { enabled: false }); + dropQueued(item.target.address); + floodCounts.delete(item.target.address); void unsubscribe(item.target.address); void item .notify( @@ -803,6 +953,8 @@ function entriesSoFar(target: CopyTarget, mint: string): number { export interface CopyBuyServices { selectWallets: typeof selectWallets; getMintBalances: typeof getMintBalances; + allWallets?: typeof allWallets; + getMintDecimals?: typeof getMintDecimals; screenToken: typeof screenToken; batchPumpTrade: typeof batchPumpTrade; measureTokensGained: typeof measureTokensGained; @@ -811,6 +963,8 @@ export interface CopyBuyServices { const buyServices: CopyBuyServices = { selectWallets, getMintBalances, + allWallets, + getMintDecimals, screenToken, batchPumpTrade, measureTokensGained, @@ -823,8 +977,16 @@ export async function mirrorBuy( notify: Notifier, services: CopyBuyServices = buyServices, ): Promise { + const epoch = executionEpoch(); + const intent = copyIntent(target); // every decision below reads state that a concurrent copy would change - return withMintLock(move.mint, () => mirrorBuyLocked(target, move, theirSol, notify, services)); + return withExecution(() => { + assertExecutionEpoch(epoch); + return withMintLock(move.mint, () => mirrorBuyLocked(target, move, theirSol, notify, services)); + }, () => { + const current = currentTarget(target); + return !!current && copyIntent(current) === intent; + }); } async function mirrorBuyLocked( @@ -834,6 +996,7 @@ async function mirrorBuyLocked( notify: Notifier, services: CopyBuyServices, ): Promise { + assertCopyCurrent(target); // a token this target was already refused is not reconsidered: the answer // will not have changed, and re-reading it turns one bad coin into a stream if (target.refusedMints?.includes(move.mint)) { @@ -916,9 +1079,11 @@ async function mirrorBuyLocked( }), ); - const heldBefore = await services.getMintBalances(wallets.map((w) => w.address), move.mint).catch( + const accountAddresses = [...new Set([...wallets, ...(services.allWallets?.() ?? [])].map((w) => w.address))]; + const heldBefore = await services.getMintBalances(accountAddresses, move.mint).catch( () => undefined, ); + assertCopyCurrent(target); if (heldBefore === undefined) { // Unknown holdings cannot establish room under either position limit, and // must not reset the cost basis as though this were an empty position. @@ -1032,6 +1197,7 @@ async function mirrorBuyLocked( * and re-reading it every time turns one bad token into a stream of alerts. */ const { verdict, info } = await screening; + assertCopyCurrent(target); if (!verdict.safe) { /* * Recorded as refused, not as copied. Those were once the same list, which @@ -1086,6 +1252,7 @@ async function mirrorBuyLocked( ).catch(() => {}); try { + assertCopyCurrent(target); const summary = await services.batchPumpTrade(wallets, { action: 'buy', mint: move.mint, @@ -1096,11 +1263,15 @@ async function mirrorBuyLocked( pool: 'auto', }); - const fills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (uncertain) db.invalidateBasis(move.mint); // the token count is the cost basis: without it there is no entry price, // and without an entry price a take-profit or stop-loss cannot fire at all - const tokensGained = await services.measureTokensGained(addresses, move.mint, heldBefore, info?.decimals); + const decimals = info?.decimals ?? await services.getMintDecimals?.(move.mint).catch(() => undefined); + const tokensGained = decimals === undefined ? 0 : await services.measureTokensGained(filled.map((r) => r.address), move.mint, heldBefore, decimals); db.recordBuy(move.mint, { solSpent: perWallet * fills, fills, @@ -1109,7 +1280,8 @@ async function mirrorBuyLocked( costSol: summary.solSpent, // `holding` was read before the limits that needed it, above freshEntry: !holding, - decimals: info?.decimals, + decimals, + quantityComplete: !uncertain, }); if (fills > 0) armCopyRules(target, move.mint, notify); @@ -1123,7 +1295,8 @@ async function mirrorBuyLocked( note: `copied ${target.label} (entry ${already + 1}/${allowed})`, }); - await notify(`👥 Copy buy done — ✅ ${summary.succeeded} ❌ ${summary.failed}${firstReason(summary)}`).catch( + await notify(`👥 Copy buy done — ✅ ${summary.succeeded} ❌ ${summary.failed}${firstReason(summary)}` + + (uncertain ? '\nSome trades may still land. Entry basis is unknown; check the wallets before another order.' : '')).catch( () => {}, ); } catch (err) { @@ -1147,6 +1320,8 @@ function firstReason(summary: { results: Array<{ ok: boolean; error?: string }> } async function mirrorSell(target: CopyTarget, move: TokenMove, notify: Notifier): Promise { + const epoch = executionEpoch(); + const intent = copyIntent(target); /* * Behind the same lock the buys queue on. * @@ -1157,7 +1332,13 @@ async function mirrorSell(target: CopyTarget, move: TokenMove, notify: Notifier) * concludes there is nothing to close, which leaves the position open on a * trade the trader has already left. */ - return withMintLock(move.mint, () => mirrorSellLocked(target, move, notify)); + return withExecution(() => { + assertExecutionEpoch(epoch); + return withMintLock(move.mint, () => mirrorSellLocked(target, move, notify)); + }, () => { + const current = currentTarget(target); + return !!current && copyIntent(current) === intent; + }); } async function mirrorSellLocked( @@ -1165,6 +1346,7 @@ async function mirrorSellLocked( move: TokenMove, notify: Notifier, ): Promise { + assertCopyCurrent(target); /* * Only exit what this trader actually put you into. * @@ -1197,6 +1379,7 @@ async function mirrorSellLocked( // only act if we actually hold it const held = await getMintBalances(wallets.map((w) => w.address), move.mint).catch(() => new Map()); + assertCopyCurrent(target); if (held.size === 0) { // the wallets that built this position may sit outside the active group, // in which case the exit silently does nothing — say so rather than not @@ -1240,6 +1423,7 @@ async function mirrorSellLocked( log.info(`Copying ${target.label} out of ${move.mint} (${percent}%)`); try { + assertCopyCurrent(target); const summary = await batchPumpTrade(wallets, { action: 'sell', mint: move.mint, @@ -1252,24 +1436,28 @@ async function mirrorSellLocked( // the proceeds, so the position's P&L reflects a copied exit as a return // rather than as the disappearance of everything it cost - const sellFills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const sellFills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (uncertain) db.invalidateBasis(move.mint); /* * What this exit made, priced before the sale is recorded — recording * changes the position the profit is measured against. */ const position = db.position(move.mint); + const decimals = position?.decimals ?? await getMintDecimals(move.mint).catch(() => undefined); const tokensSold = await measureTokensSold( - wallets.map((w) => w.address), + filled.map((r) => r.address), move.mint, held, - position?.decimals, + decimals, ); const outcome = summary.solReceived !== undefined ? exitResult(position, tokensSold, summary.solReceived) : null; - if (summary.solReceived !== undefined && sellFills > 0) { - db.recordSell(move.mint, summary.solReceived, sellFills); + if (sellFills > 0) { + db.recordSell(move.mint, summary.solReceived ?? 0, sellFills, uncertain ? undefined : tokensSold || undefined); } db.appendTradeLog({ @@ -1286,6 +1474,7 @@ async function mirrorSellLocked( `👥 ${h(target.label)} sold ${percent}%\n${move.mint}\n\n` + `Mirrored — ✅ ${summary.succeeded} ❌ ${summary.failed}` + (outcome ? `\n${formatExit(outcome)}` : '') + + (uncertain ? '\nSome trades may still land. Entry basis and proceeds are unknown; check the wallets.' : '') + `${firstReason(summary)}`, ).catch(() => {}); } catch (err) { diff --git a/src/services/execution.ts b/src/services/execution.ts new file mode 100644 index 0000000..de2d0de --- /dev/null +++ b/src/services/execution.ts @@ -0,0 +1,95 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** A cancelled operation has not been authorised to submit another transaction. */ +export class ExecutionCancelledError extends Error { + constructor() { + super('The account changed while this action was waiting. Start the action again.'); + this.name = 'ExecutionCancelledError'; + } +} + +interface ExecutionContext { + epoch: number; + owner: { active: boolean }; + guards: ReadonlyArray<() => boolean>; +} + +const context = new AsyncLocalStorage(); +let epoch = 0; +let maintenance = false; +let tail: Promise = Promise.resolve(); + +export function executionEpoch(): number { + return epoch; +} + +export function assertExecutionEpoch(expected: number): void { + if (maintenance || expected !== epoch) throw new ExecutionCancelledError(); +} + +/** Check immediately before submitting, including after an asynchronous build. */ +export function assertExecutionCurrent(): void { + const current = context.getStore(); + if (current && (!current.owner.active || current.guards.some((guard) => !guard()))) { + throw new ExecutionCancelledError(); + } + assertExecutionEpoch(current?.epoch ?? epoch); +} + +function enqueue(fn: () => Promise): Promise { + const run = tail.then(fn); + tail = run.then(() => undefined, () => undefined); + return run; +} + +/** + * Hold one operation through its before/after measurements and ledger writes. + * + * The bot, watcher and copy socket share wallets. Serialising complete operations + * keeps their balance checks and measurements from consuming each other's funds. + * A batch still runs its wallets concurrently. Nested engine calls reuse the + * caller's operation, so callers can protect bookkeeping without taking two locks. + */ +export async function withExecution(fn: () => Promise, authorize?: () => boolean): Promise { + const current = context.getStore(); + if (current) { + assertExecutionCurrent(); + if (!authorize) return fn(); + return context.run({ ...current, guards: [...current.guards, authorize] }, async () => { + assertExecutionCurrent(); + return fn(); + }); + } + + const expected = epoch; + assertExecutionEpoch(expected); + return enqueue(async () => { + assertExecutionEpoch(expected); + const owner = { active: true }; + return context.run({ epoch: expected, owner, guards: authorize ? [authorize] : [] }, async () => { + try { + assertExecutionCurrent(); + return await fn(); + } finally { + // Detached callbacks cannot retain the completed operation's lock. + owner.active = false; + } + }); + }); +} + +/** + * Invalidate queued work and stop further submissions before deleting keys. + * Active operations finish their result processing before maintenance runs; + * their submission checks reject any transaction they have not sent yet. + */ +export async function withExecutionMaintenance(fn: () => Promise): Promise { + if (context.getStore() || maintenance) throw new ExecutionCancelledError(); + maintenance = true; + epoch++; + try { + return await enqueue(fn); + } finally { + maintenance = false; + } +} diff --git a/src/services/jupdata.ts b/src/services/jupdata.ts index 1b1d63f..c45608f 100644 --- a/src/services/jupdata.ts +++ b/src/services/jupdata.ts @@ -1,4 +1,6 @@ -import { fetchJson, errMessage } from '../util.js'; +import { errMessage } from '../util.js'; +import { endpoints } from '../config.js'; +import { fetchJupiterJson } from './jupiter-client.js'; import { log } from '../logger.js'; /** @@ -67,8 +69,8 @@ interface RawJupToken { */ export async function getJupTokenData(mint: string, timeoutMs = 2500): Promise { try { - const res = await fetchJson( - `https://lite-api.jup.ag/tokens/v2/search?query=${mint}`, + const res = await fetchJupiterJson( + `${endpoints.jupiterTokens}?query=${encodeURIComponent(mint)}`, { timeoutMs }, ); const t = res.find((x) => x.id === mint); @@ -86,7 +88,9 @@ export async function getJupTokenData(mint: string, timeoutMs = 2500): Promise 0 ? (organic / total) * 100 : undefined, organicScore: t.organicScore, organicScoreLabel: t.organicScoreLabel, - topHoldersPct: t.audit?.topHoldersPercentage, + // The API's conditional audit attributes can be null. Null is an absent + // answer, never a known zero-percent holder concentration. + topHoldersPct: t.audit?.topHoldersPercentage ?? undefined, mintAuthorityDisabled: t.audit?.mintAuthorityDisabled, freezeAuthorityDisabled: t.audit?.freezeAuthorityDisabled, firstPoolAt: t.firstPool?.createdAt ? Date.parse(t.firstPool.createdAt) : undefined, diff --git a/src/services/jupiter-client.ts b/src/services/jupiter-client.ts new file mode 100644 index 0000000..caacda4 --- /dev/null +++ b/src/services/jupiter-client.ts @@ -0,0 +1,127 @@ +import { config } from '../config.js'; + +type JupiterRequestInit = RequestInit & { timeoutMs?: number }; +interface ClientOptions { + baseUrl: string; + apiKey: string; + requestIntervalMs: number; +} + +function aborted(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('Jupiter request aborted.', 'AbortError'); +} + +/** Enforce the deadline even when a transport or response body ignores abort. */ +function withAbort(promise: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { cleanup(); reject(aborted(signal)); }; + const cleanup = () => signal.removeEventListener('abort', onAbort); + if (signal.aborted) { void promise.catch(() => {}); reject(aborted(signal)); return; } + signal.addEventListener('abort', onAbort, { once: true }); + promise.then((value) => { cleanup(); resolve(value); }, (error: unknown) => { cleanup(); reject(error); }); + }); +} + +/** Separate instances support isolated offline checks; application callers share the instance below. */ +export function createJupiterClient(options: ClientOptions) { + const base = new URL(options.baseUrl); + if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || base.pathname !== '/') { + throw new Error('Jupiter client requires an HTTPS origin.'); + } + if (!Number.isSafeInteger(options.requestIntervalMs) || options.requestIntervalMs < 0 || options.requestIntervalMs > 60_000) { + throw new Error('Jupiter request interval must be an integer between 0 and 60000 milliseconds.'); + } + + interface PendingRequest { + signal: AbortSignal; + start: () => void; + cancel: () => void; + } + const pending: PendingRequest[] = []; + let nextStartAt = 0; + let timer: ReturnType | undefined; + + function pump(): void { + if (timer !== undefined) { clearTimeout(timer); timer = undefined; } + if (pending.length === 0) return; + const waitMs = Math.max(0, nextStartAt - performance.now()); + if (waitMs > 0) { timer = setTimeout(pump, waitMs); return; } + const request = pending.shift()!; + request.signal.removeEventListener('abort', request.cancel); + if (request.signal.aborted) { request.cancel(); pump(); return; } + // Only requests actually dispatched consume a slot. Expired requests are + // removed from the queue, so they cannot defer a subsequent live request. + nextStartAt = performance.now() + options.requestIntervalMs; + request.start(); + if (pending.length > 0) pump(); + } + + function enqueue(signal: AbortSignal, task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const request: PendingRequest = { + signal, + start: () => { void task().then(resolve, reject); }, + cancel: () => { + const index = pending.indexOf(request); + if (index >= 0) pending.splice(index, 1); + signal.removeEventListener('abort', request.cancel); + reject(aborted(signal)); + pump(); + }, + }; + if (signal.aborted) { reject(aborted(signal)); return; } + signal.addEventListener('abort', request.cancel, { once: true }); + pending.push(request); + pump(); + }); + } + + return async function fetchJupiterJson(url: string, init: JupiterRequestInit = {}): Promise { + const target = new URL(url); + if (target.protocol !== 'https:' || target.origin !== base.origin || target.username || target.password) { + throw new Error('Jupiter requests must use the configured HTTPS origin.'); + } + const { timeoutMs = 20_000, signal: callerSignal, ...rest } = init; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { + throw new Error('Jupiter request timeout must be a positive integer number of milliseconds.'); + } + const headers = new Headers(rest.headers); + // A caller cannot override or accidentally forward a stale API key. + headers.delete('x-api-key'); + if (options.apiKey) headers.set('x-api-key', options.apiKey); + const ctrl = new AbortController(); + const onCallerAbort = () => ctrl.abort(callerSignal?.reason); + if (callerSignal?.aborted) onCallerAbort(); + else callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); + const deadline = setTimeout(() => ctrl.abort(new DOMException('Jupiter request timed out, including queue wait.', 'TimeoutError')), timeoutMs); + try { + return await enqueue(ctrl.signal, async () => { + // Refuse redirects rather than allowing an authenticated request to + // forward its key to an unvalidated host. + const response = await withAbort(fetch(target.href, { ...rest, headers, signal: ctrl.signal, redirect: 'error' }), ctrl.signal); + const body = await withAbort(response.text(), ctrl.signal); + if (!response.ok) { + const detail = options.apiKey ? body.split(options.apiKey).join('[redacted]') : body; + throw new Error(`HTTP ${response.status} from ${target.host}: ${detail.slice(0, 300)}`); + } + return body ? JSON.parse(body) as T : {} as T; + }); + } catch (error) { + // JSON parsing and transport errors may also include an echoed key. + // Create a clean error so an already-captured stack cannot retain it. + if (options.apiKey && error instanceof Error && error.message.includes(options.apiKey)) { + const message = error.message.split(options.apiKey).join('[redacted]'); + const safeError = error instanceof SyntaxError ? new SyntaxError(message) : new Error(message); + safeError.name = error.name; + throw safeError; + } + throw error; + } finally { + clearTimeout(deadline); + callerSignal?.removeEventListener('abort', onCallerAbort); + } + }; +} + +/** Quotes, swaps, prices and token metadata share one gateway rate allowance. */ +export const fetchJupiterJson = createJupiterClient(config.jupiter); diff --git a/src/services/locks.ts b/src/services/locks.ts index 3329aa6..0aed049 100644 --- a/src/services/locks.ts +++ b/src/services/locks.ts @@ -5,23 +5,11 @@ import { log } from '../logger.js'; import { config } from '../config.js'; /** - * What a token's vesting contracts actually say. - * - * The concentration number every index reports counts a vesting vault as a - * holder, because from the outside that is what it is: one address, a large - * balance. Measured on a live launch that read 63.5% concentrated when 50.2% - * of it was locked until the year 2095 — supply nobody alive will sell, judged - * identically to a whale who can hit the book this block. - * - * The same read catches the opposite trick. A "vesting stream" whose start and - * end are one second apart is not vesting, it is an airdrop wearing vesting's - * clothes: on that same launch, 110 million tokens went to nine wallets in the - * eight seconds after the coin was created, every one of them emptied on - * arrival. The launch index scored its insider share at 0%. - * - * Streamflow only. It is what pump.fun launches use, and a lock this cannot - * read is simply not discounted — an unknown locker leaves the number exactly - * as strict as it is today. + * Streamflow outstanding balances, final schedule dates and short completed + * distributions. The decoded subset omits withdrawal rates, cliffs and + * cancellation/update permissions, so it does not prove an unavailable amount. + * Safety keeps these balances in concentration until that proof and a matched + * counted vault identity are available. */ const STREAMFLOW_PROGRAM = 'strmRqUCoQUgGUan5YhzUZa6KqdzwX5L6FpUxfmKg5m'; @@ -44,16 +32,7 @@ const OFF = { depositedAmount: 417, } as const; -/** - * The shipped answer to "how far away must an unlock be before it stops - * counting as supply that can land on you". - * - * A year is longer than any memecoin's life, so it is the safe place to start - * — but it is a judgement, not a fact. What the concentration limit is really - * asking is whether this can reach the book while you are in the position, and - * for somebody whose positions close in minutes a ninety-day cliff answers no - * just as firmly. So the line is a setting, and this is only its default. - */ +/** Stored for compatibility; no horizon currently authorizes a safety discount. */ export const DEFAULT_LOCK_HORIZON_DAYS = 365; export const DAY_MS = 24 * 60 * 60 * 1000; @@ -77,20 +56,14 @@ const STREAM_PAGE_SIZE = 1000; const MAX_STREAM_PAGES = 5; export interface LockedSupply { - /** Share of total supply this stream is still holding. */ + /** Share of supply deposited but not withdrawn. It may already be claimable. */ pct: number; - /** When it releases. */ + /** Final schedule date, not evidence that all tokens stay locked until then. */ unlockAt: number; } export interface TokenLocks { - /** - * Every stream still holding supply, and when each one lets go. - * - * Per stream rather than one "locked" number, because how far away an unlock - * has to be before it stops mattering is the operator's setting. Collapsing - * it here would bake one answer into the read. - */ + /** Outstanding balances with future final dates; some may already be claimable. */ locked: LockedSupply[]; /** Share of supply handed out through streams that never actually locked. */ launchDistPct: number; @@ -98,7 +71,7 @@ export interface TokenLocks { launchDistWallets: number; } -/** Share of supply that will not unlock for at least `horizonMs`. */ +/** Outstanding balances whose final schedule date exceeds the horizon. Display only. */ export function lockedBeyond(locked: LockedSupply[], horizonMs: number, now = Date.now()): number { return locked.filter((l) => l.unlockAt - now >= horizonMs).reduce((sum, l) => sum + l.pct, 0); } @@ -204,16 +177,17 @@ async function streamAccounts(mint: string, timeoutMs: number): Promise(); let launchDist = 0n; - const pct = (v: bigint): number => (Number(v) / Number(supply)) * 100; + const pct = (v: bigint): number => Number((v * 100_000_000n + supply - 1n) / supply) / 1_000_000; for (const s of streams) { + if (s.deposited < 0n || s.withdrawn < 0n || s.withdrawn > s.deposited || + ![s.start, s.end, s.canceledAt].every(Number.isSafeInteger) || s.end < s.start) { + throw new Error('Invalid vesting stream'); + } /* * Released, and released the moment it was created. An allocation routed * through a vesting program so a dashboard reads it as vested. @@ -272,7 +251,7 @@ export function summariseLocks(streams: Stream[], supply: bigint, now = Date.now * recipient's either way, and waiting for it would miss streams set up * seconds before a copy is decided. */ - if (s.end - s.start <= INSTANT_STREAM_MS && s.end <= now) { + if (s.canceledAt === 0 && s.end - s.start <= INSTANT_STREAM_MS && s.end <= now) { launchDist += s.deposited; recipients.add(s.recipient); continue; diff --git a/src/services/mintauth.ts b/src/services/mintauth.ts index ec526c7..d0c56f1 100644 --- a/src/services/mintauth.ts +++ b/src/services/mintauth.ts @@ -1,6 +1,7 @@ import { PublicKey } from '@solana/web3.js'; import { TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, ExtensionType, unpackMint, getExtensionTypes, @@ -8,6 +9,7 @@ import { getTransferHook, getPermanentDelegate, getDefaultAccountState, + getPausableConfig, } from '@solana/spl-token'; import { rpc } from '../chains/solana.js'; import { retry } from '../util.js'; @@ -72,6 +74,7 @@ export function parseMintAccount(data: Uint8Array): MintAuthorities | null { const buf = Buffer.from(data); const mintAuthorityOption = buf.readUInt32LE(0); const freezeAuthorityOption = buf.readUInt32LE(46); + if (![0, 1].includes(mintAuthorityOption) || ![0, 1].includes(freezeAuthorityOption) || buf[45] !== 1) return null; return { mintAuthority: @@ -126,6 +129,21 @@ export function parseTrapExtensions(data: Uint8Array, owner: string): string[] { if (getExtensionTypes(mint.tlvData).includes(ExtensionType.NonTransferable)) { traps.push('This token is non-transferable and can never be sold'); } + const pausable = getPausableConfig(mint); + if (pausable && (pausable.paused || !pausable.authority.equals(PublicKey.default))) { + traps.push('A pause authority can stop every transfer for this mint'); + } + const reviewed = new Set([ + ExtensionType.Uninitialized, ExtensionType.TransferFeeConfig, ExtensionType.MintCloseAuthority, + ExtensionType.ConfidentialTransferMint, ExtensionType.DefaultAccountState, ExtensionType.NonTransferable, + ExtensionType.InterestBearingConfig, ExtensionType.PermanentDelegate, ExtensionType.TransferHook, + ExtensionType.MetadataPointer, ExtensionType.TokenMetadata, ExtensionType.GroupPointer, + ExtensionType.TokenGroup, ExtensionType.GroupMemberPointer, ExtensionType.TokenGroupMember, + ExtensionType.ScaledUiAmountConfig, ExtensionType.PausableConfig, ExtensionType.PermissionedBurn, + ]); + if (getExtensionTypes(mint.tlvData).some((type) => !reviewed.has(type))) { + traps.push('Token-2022 has an extension whose transfer behavior has not been reviewed'); + } } catch { // an unparseable extension block is itself worth saying out loud return ['Token-2022 extensions could not be read']; @@ -140,10 +158,14 @@ export async function getMintAuthorities(mint: string): Promise rpc().getAccountInfo(new PublicKey(mint)), { attempts: 2 }); if (!info) return null; + const owner = info.owner.toBase58(); + if (owner !== TOKEN_PROGRAM_ID.toBase58() && owner !== TOKEN_2022_PROGRAM_ID.toBase58()) return null; + // The SDK validates account size and the Token-2022 account type byte. + unpackMint(new PublicKey(mint), info, info.owner); + const base = parseMintAccount(info.data); if (!base) return null; - const owner = info.owner.toBase58(); const token2022 = owner === TOKEN_2022_PROGRAM_ID.toBase58(); return { ...base, token2022, traps: parseTrapExtensions(info.data, owner) }; } catch { diff --git a/src/services/pnl.ts b/src/services/pnl.ts index 5fad457..db87a3e 100644 --- a/src/services/pnl.ts +++ b/src/services/pnl.ts @@ -46,11 +46,12 @@ export interface PositionPnl { */ export function entryPrice(pos: PositionRecord | undefined): number | null { if (!pos) return null; + if (pos.basisKnown === false || (pos.basisKnown === undefined && pos.sellFills > 0)) return null; // the position on the books, not every one this coin has ever been — see the // note on basisSol in the store const sol = pos.basisSol ?? pos.investedSol; const tokens = pos.basisTokens ?? pos.tokensBought; - if (sol <= 0 || tokens <= 0) return null; + if (!Number.isFinite(sol) || !Number.isFinite(tokens) || sol <= 0 || tokens <= 0) return null; return sol / tokens; } @@ -70,15 +71,16 @@ export function formatEntry(pos: PositionRecord | undefined, nowSol: number | nu export function positionPnl(pos: PositionRecord, currentValueSol: number): PositionPnl { const investedSol = pos.investedSol; const realisedSol = pos.realisedSol; - const netSol = realisedSol + currentValueSol - investedSol; + const cost = costOf(pos); + const netSol = realisedSol + currentValueSol - cost; return { investedSol, realisedSol, currentValueSol, netSol, - netPct: investedSol > 0 ? (netSol / investedSol) * 100 : 0, - inProfitOnRealised: realisedSol >= investedSol && investedSol > 0, + netPct: cost > 0 ? (netSol / cost) * 100 : 0, + inProfitOnRealised: realisedSol >= cost && cost > 0, }; } @@ -107,12 +109,12 @@ export function exitResult( solReceived: number, ): { profitSol: number; pct: number } | null { if (!pos || tokensSold <= 0 || solReceived <= 0) return null; - - const sol = pos.basisSol ?? pos.investedSol; + const entry = entryPrice(pos); + if (entry === null) return null; const tokens = pos.basisTokens ?? pos.tokensBought; - if (sol <= 0 || tokens <= 0) return null; + if (tokensSold > tokens * (1 + 1e-9)) return null; - const costOfSold = (sol / tokens) * tokensSold; + const costOfSold = entry * tokensSold; if (costOfSold <= 0) return null; return { diff --git a/src/services/portfolio.ts b/src/services/portfolio.ts index f1d1e61..2809dc9 100644 --- a/src/services/portfolio.ts +++ b/src/services/portfolio.ts @@ -175,11 +175,14 @@ export function aggregateToken(portfolio: Portfolio, mint: string): { let totalUsd = 0; for (const b of portfolio.solana) { - const t = b.tokens.find((x) => x.mint === mint); - if (!t || t.amount === 0) continue; - holders.push({ label: b.label, address: b.address, amount: t.amount, usd: t.usdValue }); - totalAmount += t.amount; - totalUsd += t.usdValue ?? 0; + const accounts = b.tokens.filter((x) => x.mint === mint && x.rawAmount > 0n); + if (accounts.length === 0) continue; + const amount = accounts.reduce((sum, t) => sum + t.amount, 0); + const priced = accounts.every((t) => t.usdValue !== undefined); + const usd = accounts.reduce((sum, t) => sum + (t.usdValue ?? 0), 0); + holders.push({ label: b.label, address: b.address, amount, ...(priced ? { usd } : {}) }); + totalAmount += amount; + totalUsd += usd; } holders.sort((a, b) => b.amount - a.amount); @@ -219,13 +222,15 @@ export function listPositions(portfolio: Portfolio): Array<{ >(); for (const b of portfolio.solana) { + const counted = new Set(); for (const t of b.tokens) { const entry = map.get(t.mint) ?? { symbol: t.symbol, totalAmount: 0, totalUsd: 0, walletCount: 0, priced: false }; entry.totalAmount += t.amount; entry.totalUsd += t.usdValue ?? 0; entry.priced = entry.priced || t.usdValue !== undefined; - entry.walletCount += 1; + if (!counted.has(t.mint)) entry.walletCount += 1; + counted.add(t.mint); map.set(t.mint, entry); } } diff --git a/src/services/prices.ts b/src/services/prices.ts index f8f0d87..184f7b5 100644 --- a/src/services/prices.ts +++ b/src/services/prices.ts @@ -1,5 +1,6 @@ import { endpoints } from '../config.js'; import { fetchJson, chunk } from '../util.js'; +import { fetchJupiterJson } from './jupiter-client.js'; import { WSOL_MINT } from '../chains/solana.js'; import { log } from '../logger.js'; @@ -56,7 +57,7 @@ export async function getSolanaPrices(mints: string[]): Promise>( + const res = await fetchJupiterJson>( `${endpoints.jupiterPrice}?ids=${group.join(',')}`, { timeoutMs: 12_000 }, ); @@ -98,7 +99,7 @@ export async function getDexscreenerPrice(mint: string): Promise p.baseToken?.address?.toLowerCase() === mint.toLowerCase(), + (p) => p.baseToken?.address === mint, ); if (pairs.length === 0) return undefined; diff --git a/src/services/reconcile.ts b/src/services/reconcile.ts index a2da0f6..a7ae0d8 100644 --- a/src/services/reconcile.ts +++ b/src/services/reconcile.ts @@ -1,10 +1,12 @@ -import { PublicKey } from '@solana/web3.js'; +import { createHash } from 'node:crypto'; +import { PublicKey, type ParsedTransactionWithMeta } from '@solana/web3.js'; +import bs58 from 'bs58'; import { rpc } from '../chains/solana.js'; import { db } from '../store/db.js'; import { allWallets } from '../store/wallets.js'; -import { detectTokenMoves, solSpent } from './copytrade.js'; import { errMessage, retry, sleep } from '../util.js'; import { log } from '../logger.js'; +import { assertExecutionEpoch, executionEpoch, withExecution } from './execution.js'; /** * Rebuild what past sales returned, by reading the chain instead of the ledger. @@ -15,9 +17,10 @@ import { log } from '../logger.js'; * wrote it down. The transactions are still on chain, which makes this * recoverable rather than merely explainable. * - * Repair, not estimate. Every figure here is a SOL balance delta from a - * confirmed transaction that reduced a token balance — the same measurement the - * live path now takes, applied after the fact. + * Repair, not estimate. Only supported, isolated swaps with an attributable + * token debit and SOL return can raise the ledger. An asset transfer alongside + * wallet funding is not evidence of a sale; uncertain transactions leave the + * scan incomplete instead of manufacturing proceeds. */ /** @@ -45,7 +48,7 @@ export interface Reconciliation { repaired: Array<{ mint: string; symbol?: string; was: number; now: number }>; /** Wallets that could not be read; their sales are still missing. */ failures: string[]; - /** True only when every wallet was read to the end. */ + /** True only when every wallet was read and candidate sales were attributable. */ complete: boolean; } @@ -70,16 +73,214 @@ export interface ReconcileServices { const reconcileServices: ReconcileServices = { rpc, paced }; +const SYSTEM_PROGRAM = '11111111111111111111111111111111'; +const TOKEN_PROGRAMS = new Set([ + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', +]); +const WSOL = 'So11111111111111111111111111111111111111112'; +const PUMP = '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P'; + +// Bounded support from the programs' published IDLs. A program ID alone does +// not distinguish a swap from liquidity removal or closing an account. +// https://github.com/jup-ag/jupiter-cpi/blob/main/idl.json +// https://github.com/pump-fun/pump-public-docs/tree/main/idl +const swapInstructions = new Map>([ + ['JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', new Set([ + 'route', 'route_with_token_ledger', 'shared_accounts_route', + 'shared_accounts_route_with_token_ledger', 'exact_out_route', 'shared_accounts_exact_out_route', + ])], + [PUMP, new Set(['sell'])], + ['pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA', new Set(['sell'])], +]); +const swapPrefixes = new Map([...swapInstructions].map(([program, names]) => [program, + new Set([...names].map((name) => createHash('sha256').update(`global:${name}`).digest('hex').slice(0, 16))), +])); +type Instruction = ParsedTransactionWithMeta['transaction']['message']['instructions'][number]; +const keyString = (key: string | PublicKey) => typeof key === 'string' ? key : key.toBase58(); +const instructionProgram = (ix: Instruction) => keyString(ix.programId); +const parsedInfo = (ix: Instruction): { type: string; info: Record } | undefined => + 'parsed' in ix && ix.parsed && typeof ix.parsed.type === 'string' && ix.parsed.info + ? ix.parsed : undefined; +const isSwap = (ix: Instruction): boolean => { + if (!('data' in ix)) return false; + try { + const prefix = Buffer.from(bs58.decode(ix.data)).subarray(0, 8).toString('hex'); + return swapPrefixes.get(instructionProgram(ix))?.has(prefix) ?? false; + } catch { return false; } +}; +const rawAmount = (value: unknown): bigint | undefined => { + if (typeof value !== 'string' || !/^\d{1,20}$/.test(value)) return undefined; + const amount = BigInt(value); + return amount <= 18_446_744_073_709_551_615n ? amount : undefined; +}; +const validLamports = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +/** + * This intentionally declines unfamiliar programs, composed swaps, and missing + * instruction/balance evidence. Raw quantities only compare the same mint. + * The return is wallet SOL net of fees/outlays, less pre-existing balances + * refunded by closed accounts; rent and old WSOL cannot become sale proceeds. + */ +function saleProceeds(tx: ParsedTransactionWithMeta, owner: string): + { mint: string; sol: number } | 'none' | 'ambiguous' { + const meta = tx.meta!; + if (!meta.preTokenBalances || !meta.postTokenBalances) return 'ambiguous'; + const keys = tx.transaction.message.accountKeys.map((a) => keyString(a.pubkey)); + const ownerIndex = keys.indexOf(owner); + if (ownerIndex < 0 || !validLamports(meta.preBalances[ownerIndex]) || + !validLamports(meta.postBalances[ownerIndex])) return 'ambiguous'; + const received = (meta.postBalances[ownerIndex]! - meta.preBalances[ownerIndex]!) / 1e9; + const accounts = new Map(); + const totals = new Map(); + for (const [balances, side] of [ + [meta.preTokenBalances, 'before'], [meta.postTokenBalances, 'after'], + ] as const) { + for (const b of balances) { + // Historical RPC responses may omit owners. Their effect is unknowable. + if (!b.owner) return 'ambiguous'; + const account = keys[b.accountIndex]; + if (!account) return 'ambiguous'; + const old = accounts.get(account); + if (old && (old.mint !== b.mint || old.owner !== b.owner)) return 'ambiguous'; + accounts.set(account, { mint: b.mint, owner: b.owner }); + if (b.owner !== owner) continue; + const amount = rawAmount(b.uiTokenAmount.amount); + const decimals = b.uiTokenAmount.decimals; + if (amount === undefined || !Number.isInteger(decimals) || decimals < 0 || decimals > 255) { + return 'ambiguous'; + } + const total = totals.get(b.mint) ?? { before: 0n, after: 0n, decimals }; + if (total.decimals !== decimals) return 'ambiguous'; + total[side] += amount; + totals.set(b.mint, total); + } + } + const sold = [...totals].filter(([mint, t]) => mint !== WSOL && t.after < t.before); + if (sold.length === 0) return 'none'; + const instructions = tx.transaction.message.instructions; + if (!Array.isArray(instructions)) return 'ambiguous'; + const swaps = instructions.flatMap((ix, i) => isSwap(ix) ? [i] : []); + if (received <= 0) { + // A recognized transfer/burn can be fully read without being a SOL sale. + // Unknown programs may have sold for unredeemed WSOL or another quote mint. + const plainTransfer = instructions.length > 0 && instructions.every((ix) => { + const program = instructionProgram(ix); + if (program === 'ComputeBudget111111111111111111111111111111') return true; + const parsed = parsedInfo(ix); + if (!parsed) return false; + if (TOKEN_PROGRAMS.has(program)) return /^(transfer(Checked)?(WithFee)?|burn(Checked)?|closeAccount)$/.test(parsed.type); + return program === SYSTEM_PROGRAM && /^transfer/.test(parsed.type) && parsed.info.source === owner; + }); + return plainTransfer ? 'none' : 'ambiguous'; + } + // Different token units have no meaningful ratio for allocating one SOL delta. + if (sold.length !== 1 || swaps.length !== 1 || + [...totals].some(([mint, t]) => mint !== WSOL && t.after > t.before)) return 'ambiguous'; + const [mint, total] = sold[0]!; + const swapIndex = swaps[0]!; + const swap = instructions[swapIndex]!; + if (!('accounts' in swap) || !swap.accounts.some((a) => keyString(a) === owner) || + !tx.transaction.message.accountKeys[ownerIndex]?.signer) return 'ambiguous'; + if (!Array.isArray(meta.innerInstructions)) return 'ambiguous'; + const all = instructions.map((ix, root) => ({ ix, root })); + for (const group of meta.innerInstructions) { + if (!Number.isInteger(group.index) || !instructions[group.index]) return 'ambiguous'; + all.push(...group.instructions.map((ix) => ({ ix, root: group.index }))); + } + // Temporary WSOL accounts can be created and closed in one transaction, + // absent from both token balance snapshots. Use their initialization evidence. + for (const { ix } of all) { + const parsed = parsedInfo(ix); + if (!parsed) continue; + const { type, info } = parsed; + if ((TOKEN_PROGRAMS.has(instructionProgram(ix)) && /^initializeAccount[23]?$/.test(type)) || + (instructionProgram(ix) === 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' && /^create/.test(type))) { + const tokenOwner = info.owner ?? info.wallet; + if (typeof info.account === 'string' && typeof info.mint === 'string' && typeof tokenOwner === 'string') { + const previous = accounts.get(info.account); + if (previous && (previous.mint !== info.mint || previous.owner !== tokenOwner)) return 'ambiguous'; + accounts.set(info.account, { mint: info.mint, owner: tokenOwner }); + } + } + } + const closes = new Set(); + let refunds = 0; + for (const { ix } of all) { + if (!TOKEN_PROGRAMS.has(instructionProgram(ix))) continue; + const parsed = parsedInfo(ix); + if (parsed?.type !== 'closeAccount' || parsed.info.destination !== owner) continue; + const account = parsed.info.account; + if (typeof account !== 'string' || closes.has(account)) return 'ambiguous'; + const index = keys.indexOf(account); + if (index < 0 || !validLamports(meta.preBalances[index]) || meta.postBalances[index] !== 0) return 'ambiguous'; + closes.add(account); + refunds += meta.preBalances[index]! / 1e9; + } + + let debit = 0n; + let wrappedReturn = 0n; + const safeOuter = new Set([ + SYSTEM_PROGRAM, ...TOKEN_PROGRAMS, + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + 'ComputeBudget111111111111111111111111111111', + ]); + for (const { ix, root } of all) { + const program = instructionProgram(ix); + if (root !== swapIndex && !safeOuter.has(program)) return 'ambiguous'; + const parsed = parsedInfo(ix); + if (program === SYSTEM_PROGRAM) { + if (!parsed) return 'ambiguous'; + const { info } = parsed; + const destination = info.destination ?? info.newAccount; + const source = info.source ?? info.fromPubkey; + const target = typeof destination === 'string' ? accounts.get(destination) : undefined; + if (root !== swapIndex && source !== owner && + (destination === owner || target?.owner === owner || closes.has(String(destination)))) return 'ambiguous'; + } + if (!TOKEN_PROGRAMS.has(program)) continue; + if (!parsed) return 'ambiguous'; + if (!/^transfer(Checked)?(WithFee)?$/.test(parsed.type)) { + if (root !== swapIndex && !/^(initializeAccount[23]?|initializeImmutableOwner|getAccountDataSize|syncNative|closeAccount)$/.test(parsed.type)) { + return 'ambiguous'; + } + continue; + } + const { info } = parsed; + const source = typeof info.source === 'string' ? accounts.get(info.source) : undefined; + const destination = typeof info.destination === 'string' ? accounts.get(info.destination) : undefined; + const amount = rawAmount(info.amount ?? (info.tokenAmount as { amount?: unknown } | undefined)?.amount); + if (source?.owner === owner && source.mint === mint) { + if (root !== swapIndex || destination?.owner === owner || amount === undefined || + !('accounts' in swap) || !swap.accounts.some((a) => keyString(a) === info.source)) return 'ambiguous'; + debit += amount; + } + if (destination?.owner === owner && destination.mint === mint) return 'ambiguous'; + if (destination?.owner === owner && destination.mint === WSOL) { + if (root !== swapIndex || amount === undefined || !closes.has(String(info.destination))) return 'ambiguous'; + wrappedReturn += amount; + } + } + if (debit !== total.before - total.after) return 'ambiguous'; + const sol = received - refunds; + if (!Number.isFinite(sol) || sol <= 0) return 'ambiguous'; + // Pump's legacy bonding curve sell pays native lamports directly. Other + // supported routes must show WSOL paid to an account redeemed to this wallet. + if (instructionProgram(swap) !== PUMP && + (wrappedReturn === 0n || sol > Number(wrappedReturn) / 1e9 + 1e-9)) return 'ambiguous'; + return { mint, sol }; +} + /** * Every sale of every mint one wallet made, in SOL that actually arrived. * - * A sale is a transaction where the wallet's balance of some token went down. - * The SOL side is read from the same transaction, so a sale routed through a - * pool nobody indexes is measured exactly as well as one that was not. + * A sale needs a supported swap instruction, its token debit, and SOL return. + * Unknown programs or combined sales leave the accounting incomplete. * - * Throws only when nothing could be read at all. A page that fails after others - * have succeeded returns what was found and says the scan is incomplete, since - * partial proceeds still beat none — they can only raise the recorded figure. + * Read failures remain errors. A signature page that fails after earlier pages + * returns their measured proceeds and says the scan is incomplete. Unreadable + * transactions or uncertain sale attribution also make the scan incomplete. */ export async function proceedsByMint( address: string, @@ -87,8 +288,10 @@ export async function proceedsByMint( onProgress?: ProgressFn, services: ReconcileServices = reconcileServices, ): Promise<{ found: Map; scanned: number; complete: boolean }> { + if (!Number.isFinite(notBefore)) throw new Error('Invalid reconciliation history boundary'); const found = new Map(); const owner = new PublicKey(address); + const signaturesSeen = new Set(); let before: string | undefined; let seen = 0; @@ -111,13 +314,17 @@ export async function proceedsByMint( pages++; if (page.length === 0) return { found, scanned, complete: !incomplete }; - const usable = page.filter((s) => !s.err).map((s) => s.signature); + const usable = page.filter((s) => { + if (signaturesSeen.has(s.signature)) { incomplete = true; return false; } + signaturesSeen.add(s.signature); + return !s.err && !(typeof s.blockTime === 'number' && s.blockTime * 1000 < notBefore); + }); for (let i = 0; i < usable.length; i += PARSE_BATCH) { let txs; try { txs = await services.paced(() => - services.rpc().getParsedTransactions(usable.slice(i, i + PARSE_BATCH), { + services.rpc().getParsedTransactions(usable.slice(i, i + PARSE_BATCH).map((s) => s.signature), { maxSupportedTransactionVersion: 0, }), ); @@ -131,45 +338,26 @@ export async function proceedsByMint( if (txs.length !== usable.slice(i, i + PARSE_BATCH).length) incomplete = true; - for (const tx of txs) { + const batch = usable.slice(i, i + PARSE_BATCH); + for (const [index, tx] of txs.slice(0, batch.length).entries()) { if (!tx?.meta) { incomplete = true; continue; } if (tx.meta.err) continue; - scanned++; - - const moves = detectTokenMoves( - (tx.meta.preTokenBalances ?? []) as never[], - (tx.meta.postTokenBalances ?? []) as never[], - address, - ); - const sold = moves.filter((m) => m.delta < 0); - if (sold.length === 0) continue; - - /* - * `solSpent` is signed from the wallet's point of view, so a sale is a - * negative spend. Fees are already inside it, which is what makes this - * the amount that arrived rather than the amount the pool quoted. - */ - const received = -solSpent( - tx.transaction.message.accountKeys as never[], - tx.meta.preBalances ?? [], - tx.meta.postBalances ?? [], - address, - ); - if (received <= 0) continue; - - /* - * A transaction that closed two positions at once splits its proceeds - * between them by size. Rare, and splitting evenly would put a large - * coin's return against a dust one. - */ - const total = sold.reduce((sum, m) => sum + Math.abs(m.delta), 0); - for (const m of sold) { - const share = total > 0 ? Math.abs(m.delta) / total : 1 / sold.length; - found.set(m.mint, (found.get(m.mint) ?? 0) + received * share); + const blockTime = tx.blockTime ?? batch[index]?.blockTime; + if (typeof blockTime !== 'number' || !Number.isFinite(blockTime)) { + incomplete = true; + continue; } + // The stopping page can straddle the boundary. Never include an older + // transaction merely because it shares a page with current positions. + if (blockTime * 1000 < notBefore) continue; + scanned++; + const proceeds = saleProceeds(tx, address); + if (proceeds === 'ambiguous') { incomplete = true; continue; } + if (proceeds === 'none') continue; + found.set(proceeds.mint, (found.get(proceeds.mint) ?? 0) + proceeds.sol); } } @@ -202,8 +390,11 @@ export async function rebuildRealised( onProgress?: ProgressFn, services: ReconcileServices = reconcileServices, ): Promise { + const expectedEpoch = executionEpoch(); const wallets = allWallets(); - const positions = db.positions(); + // Database records are mutable references. Retain the identity of the ledger + // read at scan start while ordinary trading continues during the RPC reads. + const positions = db.positions().map(({ mint, symbol, firstBuyAt }) => ({ mint, symbol, firstBuyAt })); // no need to read further back than the first position was opened const earliest = positions.reduce( @@ -235,15 +426,20 @@ export async function rebuildRealised( } const repaired: Reconciliation['repaired'] = []; - for (const pos of positions) { - const onChain = chain.get(pos.mint); - if (onChain === undefined) continue; - // a tenth of a milli-SOL of drift is rounding, not a missing sale - if (onChain <= pos.realisedSol + 0.0001) continue; - - repaired.push({ mint: pos.mint, symbol: pos.symbol, was: pos.realisedSol, now: onChain }); - db.setRealised(pos.mint, onChain); - } + await withExecution(async () => { + assertExecutionEpoch(expectedEpoch); + for (const snapshot of positions) { + const pos = db.position(snapshot.mint); + if (!pos || pos.firstBuyAt !== snapshot.firstBuyAt) continue; + const onChain = chain.get(pos.mint); + if (onChain === undefined) continue; + // Re-read under the execution gate: a sale that completed during the + // scan may already have raised proceeds beyond the historical result. + if (onChain <= pos.realisedSol + 0.0001) continue; + repaired.push({ mint: pos.mint, symbol: pos.symbol, was: pos.realisedSol, now: onChain }); + db.setRealised(pos.mint, onChain); + } + }); log.info( `Reconciled ${repaired.length} position(s) from ${transactionsScanned} transactions across ` + diff --git a/src/services/rugcheck.ts b/src/services/rugcheck.ts index 99d4e6a..aff070b 100644 --- a/src/services/rugcheck.ts +++ b/src/services/rugcheck.ts @@ -33,17 +33,9 @@ export interface RugcheckReport { /** 1 is clean. Anything into the tens is the index objecting to something. */ score: number; risks: RugcheckRisk[]; - /** Concentration with pools and the launch wallet taken out. */ + /** Concentration with known pools taken out; the launch wallet stays counted. */ top10Pct?: number; - /** - * How much of that concentration is a vesting vault rather than a holder. - * - * Reported alongside rather than removed, because the index knows an account - * is a locker but not for how long. The unlock date is read on chain, and - * only what is genuinely locked past the horizon gets discounted — this is - * the ceiling on that discount, so a lock outside the counted ten can never - * subtract from a number it was not part of. - */ + /** Indexed locker share, retained as evidence without any safety discount. */ lockerPct?: number; /** Share held by wallets the index believes are one person. */ insiderPct?: number; @@ -53,6 +45,7 @@ export interface RugcheckReport { /** Every wallet, not just the twenty largest. */ totalHolders?: number; creatorPct?: number; + creator?: string; /** How many tokens this developer has launched before this one. */ creatorPriorTokens?: number; /** The index's own verdict, when it has one. */ @@ -93,8 +86,8 @@ interface RawReport { * Holders that are somebody's position, rather than the market itself. * * A pool holding supply is liquidity, not concentration — it is what you sell - * into. The launch wallet is counted separately because a limit on the - * developer is a different question from a limit on the top ten. + * into. The launch wallet stays counted even when its independent balance + * lookup fails, so a missing developer check cannot hide its concentration. */ function counted(raw: RawReport): RawHolder[] | undefined { const holders = raw.topHolders; @@ -103,10 +96,16 @@ function counted(raw: RawReport): RawHolder[] | undefined { const known = raw.knownAccounts ?? {}; const real = holders.filter((h) => { const type = h.owner ? known[h.owner]?.type : undefined; - return type !== 'AMM' && type !== 'CREATOR'; + return type !== 'AMM'; }); - - return real.slice(0, 10); + const owners = new Map(); + for (const h of real) { + if (typeof h.owner !== 'string' || !h.owner || typeof h.pct !== 'number' || + !Number.isFinite(h.pct) || h.pct < 0 || h.pct > 100) return undefined; + const prior = owners.get(h.owner); + owners.set(h.owner, { ...h, pct: (prior?.pct ?? 0) + h.pct }); + } + return [...owners.values()].sort((a, b) => b.pct! - a.pct!).slice(0, 10); } function concentration(raw: RawReport): number | undefined { @@ -133,7 +132,7 @@ function lockedShare(raw: RawReport): number | undefined { function insiderShare(raw: RawReport): number | undefined { const holders = raw.topHolders; - if (!holders) return undefined; + if (!holders || holders.some((h) => typeof h.pct !== 'number' || !Number.isFinite(h.pct) || h.pct < 0 || h.pct > 100)) return undefined; const known = raw.knownAccounts ?? {}; const flagged = holders.filter( (h) => h.insider && (h.owner ? known[h.owner]?.type : undefined) !== 'AMM', @@ -173,7 +172,8 @@ export async function getRugcheck(mint: string, timeoutMs = 4000): Promise typeof trap !== 'string'))) { + reasons.push('The transfer extension report is invalid.'); + } + if (reasons.length > 0) return { safe: false, reasons, notes }; + const percentageFields = new Set(['top10Pct', 'top10PctUpperBound', 'creatorHoldsPct', 'lockerPct', 'insiderPct', 'launchDistPct']); + for (const key of ['top10Pct', 'top10PctUpperBound', 'creatorHoldsPct', 'lockerPct', 'insiderPct', 'launchDistPct', 'liquidityUsd', 'volume1h', 'pairCreatedAt', 'devMints', 'traders5m'] as const) { + const value = info[key]; + if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || + (percentageFields.has(key) && value > 100) || + ((key === 'devMints' || key === 'traders5m') && !Number.isInteger(value)))) { + reasons.push(`Token fact ${key} is invalid — its risk is unknown.`); + } + } + if (reasons.length > 0) return { safe: false, reasons, notes }; + /* * Freeze authority is the Solana honeypot. * @@ -198,21 +218,19 @@ export function assessToken(info: TokenInfo, limits: SafetyLimits = DEFAULT_SAFE if (info.mintAuthority) { reasons.push('Mint authority is live — supply can be created and sold into the pool at any time.'); + } else if (info.mintAuthority === undefined) { + reasons.push('Could not read the mint authority — supply risk is unknown, not absent.'); } } - const discount = lockDiscount(info, limits.lockHorizonDays); - const concentration = info.top10Pct === undefined ? undefined : Math.max(0, info.top10Pct - discount); + const concentration = info.top10Pct === undefined ? undefined : Math.max(info.top10Pct, info.top10PctUpperBound ?? 0); - if (concentration === undefined) { - if (info.holdersUnavailable) { - reasons.push('Holder distribution could not be read — concentration is unknown, not zero.'); - } + if (info.holdersUnavailable || concentration === undefined) { + reasons.push('Holder distribution could not be read — concentration is unknown, not zero.'); } else if (concentration > limits.maxTop10Pct) { - reasons.push( - `Top 10 hold ${concentration.toFixed(1)}% of supply, over the ${limits.maxTop10Pct}% limit.` + - (discount > 0 ? ` (${discount.toFixed(1)}% locked long-term was not counted.)` : ''), - ); + reasons.push(info.top10Pct !== undefined && info.top10Pct <= limits.maxTop10Pct + ? `The holder sample cannot prove the ${limits.maxTop10Pct}% limit: unsampled supply could put the top 10 at ${concentration.toFixed(1)}%.` + : `Top 10 hold ${info.top10Pct!.toFixed(1)}% of supply, over the ${limits.maxTop10Pct}% limit.`); } if (info.creatorHoldsPct !== undefined && info.creatorHoldsPct > limits.maxDevPct) { @@ -220,6 +238,9 @@ export function assessToken(info: TokenInfo, limits: SafetyLimits = DEFAULT_SAFE `The launch wallet holds ${info.creatorHoldsPct.toFixed(1)}% of supply, over the ${limits.maxDevPct}% limit.`, ); } + if (info.creatorBalanceUnavailable || (info.creator && info.creatorHoldsPct === undefined)) { + reasons.push('The launch wallet balance could not be read — developer concentration is unknown.'); + } /* * Liquidity is only meaningful once there is a pool. A token still on its @@ -348,16 +369,6 @@ export function assessToken(info: TokenInfo, limits: SafetyLimits = DEFAULT_SAFE : `${connected.pct.toFixed(1)}% held by connected wallets.`, ); } - if (discount > 0) { - notes.push( - (() => { - const until = furthestUnlock(info.lockedSupply ?? []); - return `${discount.toFixed(1)}% of supply is locked${ - until ? ` until ${new Date(until).getUTCFullYear()}` : '' - } and was not counted as concentration.`; - })(), - ); - } if (info.pairCreatedAt && Date.now() - info.pairCreatedAt < 3_600_000) { notes.push(`Pair is ${Math.round((Date.now() - info.pairCreatedAt) / 60_000)} minutes old.`); } @@ -374,27 +385,6 @@ export function formatHorizon(days: number): string { return `${days} days`; } -/** - * How much of the concentration figure is supply that cannot reach the book. - * - * Two sources have to agree before anything is discounted. The launch index - * says which of the counted ten is a locker; the chain says how long its - * contents are locked for. Taking the smaller of the two means a lock sitting - * outside the counted ten cannot subtract from a number it was never part of, - * and a locker the chain could not read is not discounted at all. - * - * Missing data discounts nothing. A concentration check that quietly relaxes - * itself when a lookup fails is worse than no check — it reads as a coin that - * passed rather than one nobody managed to look at. - */ -function lockDiscount(info: TokenInfo, horizonDays: number): number { - const eligible = info.lockerPct; - if (eligible === undefined || info.lockedSupply === undefined) return 0; - - const verified = lockedBeyond(info.lockedSupply, Math.max(0, horizonDays) * DAY_MS); - return Math.max(0, Math.min(eligible, verified)); -} - /** * Supply in one person's hands across several wallets, from either source. * @@ -418,14 +408,14 @@ function connectedShare(info: TokenInfo): { pct: number; fromLaunch: boolean } | /** A pump.fun token that has not graduated has a curve, not a pool. */ function isOnCurve(info: TokenInfo): boolean { - return info.isPumpFun === true && info.curveComplete !== true; + return info.isPumpFun === true && info.curveComplete === false; } /** One line per limit, for the screen that configures them. */ export function describeLimits(limits: SafetyLimits): string[] { return [ `Top 10 holders: refuse above ${limits.maxTop10Pct}%`, - `Locked supply: ignore what cannot unlock for ${formatHorizon(limits.lockHorizonDays)}`, + 'Vesting balances: counted until unlock schedule and holder identity are verified', `Launch wallet: refuse above ${limits.maxDevPct}%`, `Mint and freeze authority: ${limits.requireRevokedAuthorities ? 'must be revoked' : 'not checked'}`, limits.minLiquidityUsd > 0 diff --git a/src/services/tokeninfo.ts b/src/services/tokeninfo.ts index 8025bb8..11d7e05 100644 --- a/src/services/tokeninfo.ts +++ b/src/services/tokeninfo.ts @@ -9,7 +9,7 @@ import { getTokenMetadata } from './metadata.js'; import { getMintAuthorities } from './mintauth.js'; import { getRugcheck } from './rugcheck.js'; import { getJupTokenData } from './jupdata.js'; -import { readTokenLocks, lockedBeyond, furthestUnlock, DEFAULT_LOCK_HORIZON_DAYS, DAY_MS, type LockedSupply } from './locks.js'; +import { readTokenLocks, type LockedSupply } from './locks.js'; import { allWallets } from '../store/wallets.js'; import type { Chain } from '../types.js'; @@ -61,6 +61,8 @@ export interface TokenInfo { creator?: string; /** How much of the supply the launcher still holds, as a percentage. */ creatorHoldsPct?: number; + /** An indexed creator percentage cannot replace a failed chain balance read. */ + creatorBalanceUnavailable?: boolean; totalSupply?: number; /** Needed to turn a raw balance into a human amount. */ @@ -71,21 +73,19 @@ export interface TokenInfo { freezeAuthority?: string | null; /** Mint belongs to Token-2022, which can attach behaviour to transfers. */ token2022?: boolean; + /** The actual mint could not be validated, even if an index knows its name. */ + mintReadUnavailable?: boolean; /** Token-2022 extensions that can stop or tax a sale. Empty = none found. */ traps?: string[]; holders?: HolderInfo[]; /** True when the holder query failed — distinct from "no holders". */ holdersUnavailable?: boolean; top10Pct?: number; + /** Conservative wallet concentration bound including all unsampled supply. */ + top10PctUpperBound?: number; /** Share of supply in a vesting vault, of the concentration figure above. */ lockerPct?: number; - /** - * Every vesting stream still holding supply, and when each one releases. - * - * Kept as the list rather than a single locked figure, because how far away - * an unlock has to be before it stops counting is the operator's setting — - * the gate applies it, this only reports what is there. - */ + /** Outstanding stream balances and final dates; these receive no safety discount. */ lockedSupply?: LockedSupply[]; /** Share handed out through streams that locked nothing, and to how many. */ launchDistPct?: number; @@ -206,7 +206,9 @@ async function fetchPairsOnChain(chainId: string, address: string): Promise p.baseToken?.address?.toLowerCase() === address.toLowerCase()); + return pairs.filter((p) => p.chainId === chainId && (chainId === 'solana' + ? p.baseToken?.address === address + : p.baseToken?.address?.toLowerCase() === address.toLowerCase())); } catch { return []; } @@ -339,29 +341,10 @@ async function loadMarketData(address: string, kind: 'solana' | 'evm'): Promise< */ const HOLDER_DEADLINE_MS = 4000; -/** - * How long a copied buy will wait for the same query. - * - * A card is read by somebody who can wait four seconds. A copied entry is a - * race, and four seconds is a materially worse fill on a token doing its first - * minutes. Measured against a live endpoint the whole lookup took 4001ms and - * the holder query contributed nothing to any of it — it timed out every time, - * while the launch index answered in 116ms with a concentration figure that is - * better anyway, because it can tell a pool from a whale. - * - * Kept as a short wait rather than removed, so the on-chain read still covers - * the case where the index is unreachable and the RPC is quick. - */ +/** Copied buys use a shorter deadline; a timed-out chain read refuses the entry. */ const FAST_HOLDER_DEADLINE_MS = 1200; -/** - * How long the lock lookup gets. - * - * Same reasoning as the holder deadline above and a different number, because - * this query is worth more. It answered in 530ms on measurement and it is what - * stops a coin being refused over supply locked until 2095 — so a copied entry - * waits for it rather than skipping it, just not indefinitely. - */ +/** Bound the informational stream scan and launch distribution lookup. */ const LOCK_DEADLINE_MS = 4000; const FAST_LOCK_DEADLINE_MS = 1500; @@ -427,11 +410,13 @@ async function readSolanaHolders(mint: string): Promise> { ]); const decimals = supplyRes.value.decimals; - const totalSupply = Number(supplyRes.value.amount) / 10 ** decimals; - if (totalSupply === 0) return { totalSupply: 0, decimals, holders: [] }; + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error('Invalid mint decimals'); + const supplyRaw = BigInt(supplyRes.value.amount); + const totalSupply = Number(supplyRaw) / 10 ** decimals; + if (supplyRaw <= 0n) return { totalSupply: 0, decimals, holders: [], holdersUnavailable: true }; const accounts = largest.value.slice(0, 20); - if (accounts.length === 0) return { totalSupply, decimals, holders: [] }; + if (accounts.length === 0) return { totalSupply, decimals, holders: [], holdersUnavailable: true }; // getTokenLargestAccounts returns token accounts, not owners — resolve them const parsed = await rpc().getMultipleParsedAccounts(accounts.map((a) => a.address)); @@ -439,30 +424,45 @@ async function readSolanaHolders(mint: string): Promise> { const curvePda = bondingCurvePda(mint).toBase58(); const ourAddresses = new Set(allWallets().filter((w) => w.kind === 'solana').map((w) => w.address)); - const holders: HolderInfo[] = []; + const grouped = new Map(); + const sampledAccounts = new Set(); + let sampledRaw = 0n; accounts.forEach((acc, i) => { + const tokenAddress = acc.address.toBase58(); + if (sampledAccounts.has(tokenAddress)) throw new Error('Duplicate largest token account'); + sampledAccounts.add(tokenAddress); const data = parsed.value[i]?.data; - const owner = + const token = data && typeof data === 'object' && 'parsed' in data - ? ((data as { parsed: { info?: { owner?: string } } }).parsed?.info?.owner ?? acc.address.toBase58()) - : acc.address.toBase58(); - - const amount = Number(acc.uiAmountString ?? 0); - if (amount === 0) return; + ? (data as { parsed: { info?: { owner?: string; mint?: string } } }).parsed?.info + : undefined; + if (token?.mint !== mint) throw new Error('Largest token account mint did not match'); + const owner = token.owner; + if (!owner) throw new Error('Largest token account owner could not be resolved'); + new PublicKey(owner); + + const raw = BigInt(acc.amount); + if (raw === 0n) return; + if (raw < 0n || raw > supplyRaw) throw new Error('Invalid token account amount'); + sampledRaw += raw; + if (sampledRaw > supplyRaw) throw new Error('Sampled balances exceed mint supply'); let tag: string | undefined; if (owner === curvePda) tag = 'bonding curve'; else if (ourAddresses.has(owner)) tag = 'you'; - holders.push({ - owner, - amount, - pctOfSupply: (amount / totalSupply) * 100, - tag, - }); + const prior = grouped.get(owner); + grouped.set(owner, { raw: (prior?.raw ?? 0n) + raw, tag }); }); + const holders: HolderInfo[] = [...grouped].map(([owner, h]) => ({ + owner, amount: Number(h.raw) / 10 ** decimals, + // Round the ratio upward so precision loss cannot understate a risk. + pctOfSupply: Number((h.raw * 100_000_000n + supplyRaw - 1n) / supplyRaw) / 1_000_000, + tag: h.tag, + })); + holders.sort((a, b) => b.amount - a.amount); // the curve is one kind of market; a graduated coin's pool is the other, @@ -474,6 +474,13 @@ async function readSolanaHolders(mint: string): Promise> { // exclude it from the "top holders" number that actually matters const realHolders = holders.filter((h) => h.tag !== 'bonding curve' && h.tag !== 'pool'); const top10Pct = realHolders.slice(0, 10).reduce((sum, h) => sum + h.pctOfSupply, 0); + // Twenty accounts cannot establish the ten largest wallets: an owner can + // split across arbitrarily many accounts. Treat every unsampled token as + // belonging to those wallets to obtain a safe upper bound, without an + // unbounded full-mint scan. Pool accounts still count towards coverage. + const knownTop10Raw = realHolders.slice(0, 10).reduce((sum, h) => sum + grouped.get(h.owner)!.raw, 0n); + const boundRaw = knownTop10Raw + supplyRaw - sampledRaw; + const top10PctUpperBound = Math.min(100, Number((boundRaw * 100_000_000n + supplyRaw - 1n) / supplyRaw) / 1_000_000); const owned = holders.filter((h) => h.tag === 'you'); const ownedAmount = owned.reduce((s, h) => s + h.amount, 0); @@ -483,6 +490,7 @@ async function readSolanaHolders(mint: string): Promise> { decimals, holders, top10Pct, + top10PctUpperBound, ownedPct: (ownedAmount / totalSupply) * 100, ownedAmount, }; @@ -519,16 +527,23 @@ async function loadCurveData(mint: string): Promise> { // large share can end the chart in one transaction. if (curve.creator && curve.tokenTotalSupply > 0n) { try { - const held = await getMintBalances([curve.creator], mint); + const [held, supplyRes] = await Promise.all([ + getMintBalances([curve.creator], mint), + rpc().getTokenSupply(new PublicKey(mint)), + ]); + const supply = BigInt(supplyRes.value.amount); + if (supply <= 0n) throw new Error('Creator balance has no valid mint supply'); const raw = held.get(curve.creator) ?? 0n; if (raw > 0n) { - out.creatorHoldsPct = (Number(raw) / Number(curve.tokenTotalSupply)) * 100; + out.creatorHoldsPct = Number((raw * 100_000_000n + supply - 1n) / supply) / 1_000_000; } else { out.creatorHoldsPct = 0; } } catch { - /* leave undefined — unknown, which the card states rather than implying zero */ + out.creatorBalanceUnavailable = true; } + } else { + out.creatorBalanceUnavailable = true; } return out; @@ -592,6 +607,8 @@ export async function getTokenInfo( merged.decimals ??= authorities.decimals; merged.token2022 = authorities.token2022; merged.traps = authorities.traps; + } else { + merged.mintReadUnavailable = true; } // on-chain metadata is the fallback, never the override — an indexed name @@ -606,11 +623,8 @@ export async function getTokenInfo( /* * The index's numbers where it has them, ours where it does not. * - * It is preferred for concentration rather than merely consulted, because - * it can name a pool and a cluster of one person's wallets and we cannot. - * Our own read stays as the fallback for the moments it is unreachable — - * and `holdersUnavailable` is cleared when it answers, since the figure is - * then known even though our RPC query was throttled out of returning it. + * Independent observations are retained at their larger value. An index + * must not relax a high on-chain concentration or creator balance. */ if (rug) { merged.rugcheckScore = rug.score; @@ -628,12 +642,13 @@ export async function getTokenInfo( * The index's concentration figure, without pretending our own read * worked. `holdersUnavailable` means one thing — the RPC refused us — * and clearing it here to signal "but we know anyway" made the card - * claim a holder list it did not have. The gate only ever complains - * when the figure is missing entirely, so supplying it is enough. + * claim a holder list it did not have. A failed chain read remains a + * refusal even when an index supplies a concentration estimate. */ - if (rug.top10Pct !== undefined) merged.top10Pct = rug.top10Pct; + if (rug.top10Pct !== undefined) merged.top10Pct = Math.max(merged.top10Pct ?? 0, rug.top10Pct); if (rug.lockerPct !== undefined) merged.lockerPct = rug.lockerPct; - if (rug.creatorPct !== undefined) merged.creatorHoldsPct ??= rug.creatorPct; + if (rug.creatorPct !== undefined) merged.creatorHoldsPct = Math.max(merged.creatorHoldsPct ?? 0, rug.creatorPct); + merged.creator ??= rug.creator; if (merged.liquidityUsd === undefined && rug.liquidityUsd !== undefined) { merged.liquidityUsd = rug.liquidityUsd; } @@ -644,8 +659,7 @@ export async function getTokenInfo( * * devMints and the five-minute trader count exist in no other source this * bot reads. The concentration and holder figures are third opinions — the - * launch index outranks them because it can name a pool, so they only fill - * gaps. + * larger reported concentration remains a reason for caution. */ if (jup) { merged.devMints = jup.devMints; @@ -654,7 +668,7 @@ export async function getTokenInfo( merged.organicPct5m = jup.organicPct5m; merged.organicScoreLabel = jup.organicScoreLabel; merged.holderCount ??= jup.holderCount; - merged.top10Pct ??= jup.topHoldersPct; + if (jup.topHoldersPct !== undefined) merged.top10Pct = Math.max(merged.top10Pct ?? 0, jup.topHoldersPct); if (merged.creatorPriorTokens === undefined && jup.devMints !== undefined) { // devMints counts this token too; prior launches are one fewer merged.creatorPriorTokens = Math.max(0, jup.devMints - 1); @@ -667,6 +681,26 @@ export async function getTokenInfo( merged.launchDistWallets = locks.launchDistWallets; } + // A creator identified by the index still needs an exhaustive chain balance. + // Its reported percentage is evidence of a larger holding, never proof of zero. + if (merged.creator && !curve.creator) { + try { + if (!authorities || authorities.supplyRaw <= 0n) throw new Error('No validated mint supply'); + const held = await Promise.race([ + getMintBalances([merged.creator], address), + new Promise((_, reject) => setTimeout( + () => reject(new Error('Creator balance lookup exceeded its deadline')), + opts.fast ? FAST_HOLDER_DEADLINE_MS : HOLDER_DEADLINE_MS, + ).unref()), + ]); + const raw = held.get(merged.creator) ?? 0n; + const pct = Number((raw * 100_000_000n + authorities.supplyRaw - 1n) / authorities.supplyRaw) / 1_000_000; + merged.creatorHoldsPct = Math.max(merged.creatorHoldsPct ?? 0, pct); + } catch { + merged.creatorBalanceUnavailable = true; + } + } + addWarnings(merged); return merged; } @@ -705,30 +739,16 @@ function addWarnings(info: TokenInfo): void { info.warnings.push('Holder distribution unavailable — the RPC rejected the query (rate limit?). Concentration is unknown, not zero.'); } - /* - * Concentration net of supply that is locked away for good. - * - * The raw figure counts a vesting vault as a holder, which on a live launch - * read 63.5% concentrated when 50.2% of it was locked until 2095. Saying - * that out loud on the card is the point — the number and the reason for it, - * rather than a quietly softened number nobody can check. - */ - const locked = Math.min( - info.lockerPct ?? 0, - info.lockedSupply === undefined - ? 0 - : lockedBeyond(info.lockedSupply, DEFAULT_LOCK_HORIZON_DAYS * DAY_MS), - ); - const free = info.top10Pct === undefined ? undefined : Math.max(0, info.top10Pct - locked); + // An end date alone does not prove that a vesting balance is unavailable. + const free = info.top10Pct; if (free !== undefined && free > 50) { info.warnings.push(`Top 10 wallets hold ${free.toFixed(1)}% of supply — heavy concentration.`); } - if (locked > 0) { - const furthest = furthestUnlock(info.lockedSupply ?? []); - const until = furthest ? new Date(furthest).getUTCFullYear() : undefined; + if (info.lockedSupply && info.lockedSupply.length > 0) { + const outstanding = info.lockedSupply.reduce((sum, stream) => sum + stream.pct, 0); info.warnings.push( - `${locked.toFixed(1)}% of supply is locked${until ? ` until ${until}` : ''} — not counted as concentration.`, + `${outstanding.toFixed(1)}% of supply remains in vesting streams. Their final dates do not prove when funds can be withdrawn; concentration receives no discount.`, ); } diff --git a/src/services/watcher.ts b/src/services/watcher.ts index 0c2282e..19334f5 100644 --- a/src/services/watcher.ts +++ b/src/services/watcher.ts @@ -1,15 +1,16 @@ import crypto from 'node:crypto'; import { db, type AutoRule } from '../store/db.js'; import { isUnlocked } from '../store/vault.js'; -import { selectWallets } from '../store/wallets.js'; +import { selectWallets, allWallets } from '../store/wallets.js'; import { batchPumpTrade, measureTokensGained, measureTokensSold, isFreshEntry } from '../trade/engine.js'; -import { getMintBalances } from '../chains/solana.js'; +import { getMintBalances, getMintDecimals } from '../chains/solana.js'; import { pricesInSol } from './price.js'; import { errMessage, escapeHtml as h } from '../util.js'; import { pollCopyTargets, syncSubscriptions, stopSubscriptions } from './copytrade.js'; import { buildPortfolio } from './portfolio.js'; -import { exitResult, formatExit } from './pnl.js'; +import { entryPrice, exitResult, formatExit } from './pnl.js'; import { log } from '../logger.js'; +import { assertExecutionCurrent, withExecution } from './execution.js'; /** * The background loop behind take-profit, stop-loss and trailing stops. @@ -34,6 +35,8 @@ export type Notifier = (text: string) => Promise; export interface WatcherTradeServices { selectWallets: typeof selectWallets; getMintBalances: typeof getMintBalances; + allWallets?: typeof allWallets; + getMintDecimals?: typeof getMintDecimals; batchPumpTrade: typeof batchPumpTrade; measureTokensGained: typeof measureTokensGained; measureTokensSold: typeof measureTokensSold; @@ -42,6 +45,8 @@ export interface WatcherTradeServices { const tradeServices: WatcherTradeServices = { selectWallets, getMintBalances, + allWallets, + getMintDecimals, batchPumpTrade, measureTokensGained, measureTokensSold, @@ -71,22 +76,7 @@ export function newRuleId(): string { * and rules built on it would fire at arbitrary prices. */ export function entryPriceSol(mint: string): number | null { - const pos = db.position(mint); - if (!pos) return null; - - /* - * The position being held, not every position this coin has ever been. - * - * The lifetime totals never come back down, so a coin sold and bought again - * reports a price blended across two unrelated trades — and a stop-loss - * measuring against it fires at a number from a position that is closed. - * Records written before the basis existed fall back to the lifetime ratio, - * which is what they were computed from anyway. - */ - const sol = pos.basisSol ?? pos.investedSol; - const tokens = pos.basisTokens ?? pos.tokensBought; - if (sol <= 0 || tokens <= 0) return null; - return sol / tokens; + return entryPrice(db.position(mint)); } /** Has this rule's condition been met? Pure, so the thresholds are testable. */ @@ -292,6 +282,7 @@ function priorityFeeFor(rule: AutoRule, configured: number, ceiling: number): nu * believes it has one. */ async function rearm(rule: AutoRule, reason: string, notify: Notifier): Promise { + if (!db.raw().rules.some((r) => r.id === rule.id && r.enabled)) return; const attempts = (rule.failedAttempts ?? 0) + 1; // the symbol comes from whoever launched the coin, and goes into HTML const label = h(rule.symbol ?? rule.mint.slice(0, 8)); @@ -326,6 +317,32 @@ export async function fire( notify: Notifier, services: WatcherTradeServices = tradeServices, ): Promise { + const intent = ruleIntent(rule); + const authorized = () => db.raw().rules.some((r) => r.id === rule.id && r.enabled && ruleIntent(r) === intent); + return withExecution(async () => { + if (!authorized()) return; + return withExecution(() => fireLocked(rule, price, notify, services), authorized); + }); +} + +function ruleIntent(rule: AutoRule): string { + return JSON.stringify({ + mint: rule.mint, kind: rule.kind, triggerPct: rule.triggerPct, + triggerPriceSol: rule.triggerPriceSol, sellPercent: rule.sellPercent, buySol: rule.buySol, + }); +} + +async function fireLocked( + rule: AutoRule, + price: number, + notify: Notifier, + services: WatcherTradeServices, +): Promise { + // A tick holds a snapshot across several network calls. Clearing automation + // during those reads must revoke that snapshot's authority to trade. + const current = db.raw().rules.find((r) => r.id === rule.id); + if (!current || !current.enabled || current.firedAt) return; + rule = current; // Marked before the attempt, never after: a crash between here and the sell // must not leave a rule that fires again on the next tick. A batch that comes // back having landed nothing is a different thing entirely, and `rearm` puts @@ -363,7 +380,12 @@ export async function fire( if (rule.kind === 'limit_buy') { // read first, so the fill can be measured and the position gets a basis const addresses = wallets.map((w) => w.address); - const heldBefore = await services.getMintBalances(addresses, rule.mint).catch(() => undefined); + const accountAddresses = [...new Set([...addresses, ...(services.allWallets?.() ?? []).map((w) => w.address)])]; + const heldBefore = await services.getMintBalances(accountAddresses, rule.mint).catch(() => undefined); + const decimals = await services.getMintDecimals?.(rule.mint).catch(() => undefined); + + assertExecutionCurrent(); + if (!db.raw().rules.some((r) => r.id === rule.id && r.enabled)) return; tradeStarted = true; const summary = await services.batchPumpTrade(wallets, { @@ -376,7 +398,10 @@ export async function fire( pool: 'auto', }); - const fills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (uncertain) db.invalidateBasis(rule.mint); confirmedFills = fills; // an order that bought nothing has not been filled, and retiring it here @@ -390,7 +415,7 @@ export async function fire( return; } - const gained = await services.measureTokensGained(addresses, rule.mint, heldBefore, undefined); + const gained = decimals === undefined ? 0 : await services.measureTokensGained(filled.map((r) => r.address), rule.mint, heldBefore, decimals); db.recordBuy(rule.mint, { solSpent: (rule.buySol ?? 0) * fills, fills, @@ -398,6 +423,8 @@ export async function fire( symbol: rule.symbol, costSol: summary.solSpent, freshEntry: isFreshEntry(heldBefore), + decimals, + quantityComplete: !uncertain, }); if (rule.failedAttempts) db.updateRule(rule.id, { failedAttempts: 0 }); db.appendTradeLog({ @@ -418,7 +445,7 @@ export async function fire( `Bought ${rule.buySol} SOL × ${wallets.length} wallets`, `✅ ${summary.succeeded} ❌ ${summary.failed}`, ...(summary.results.some((r) => r.confirmationUnknown) - ? ['Some trades may still land. Check the wallets before placing another order.'] + ? ['Some trades may still land. Entry basis and proceeds are unknown; check the wallets before another order.'] : []), ].join('\n'), ).catch(() => {}); @@ -426,6 +453,8 @@ export async function fire( } const holders = await services.getMintBalances(wallets.map((w) => w.address), rule.mint); + assertExecutionCurrent(); + if (!db.raw().rules.some((r) => r.id === rule.id && r.enabled)) return; if (holders.size === 0) { await notify(`⚠️ ${label}: ${describe(rule)} triggered, but no wallet holds it any more.`).catch(() => {}); return; @@ -444,7 +473,10 @@ export async function fire( // a rule that fired returned SOL to the wallets; without this the position // keeps its whole cost and none of its proceeds, and a stop loss that saved // most of the money reports as having lost all of it - const fills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (uncertain) db.invalidateBasis(rule.mint); confirmedFills = fills; // nothing landed, so the protection did not run — put it back @@ -462,16 +494,17 @@ export async function fire( * changes the position the profit is measured against. */ const position = db.position(rule.mint); + const decimals = position?.decimals ?? await services.getMintDecimals?.(rule.mint).catch(() => undefined); const tokensSold = await services.measureTokensSold( - wallets.map((w) => w.address), + filled.map((r) => r.address), rule.mint, holders, - position?.decimals, + decimals, ); const outcome = summary.solReceived !== undefined ? exitResult(position, tokensSold, summary.solReceived) : null; - if (summary.solReceived !== undefined && fills > 0) { - db.recordSell(rule.mint, summary.solReceived, fills); + if (fills > 0) { + db.recordSell(rule.mint, summary.solReceived ?? 0, fills, uncertain ? undefined : tokensSold || undefined); } if (rule.failedAttempts) db.updateRule(rule.id, { failedAttempts: 0 }); @@ -547,8 +580,19 @@ export function describe(rule: AutoRule): string { export async function runDueDca( notify: Notifier, services: WatcherTradeServices = tradeServices, +): Promise { + return withExecution(() => runDueDcaLocked(notify, services)); +} + +async function runDueDcaLocked( + notify: Notifier, + services: WatcherTradeServices, ): Promise { for (const plan of db.dueDcaPlans()) { + if (!db.dcaPlans().some((p) => p.id === plan.id && p.enabled)) continue; + const intent = { mint: plan.mint, buySol: plan.buySol, intervalMinutes: plan.intervalMinutes, roundsTotal: plan.roundsTotal }; + const authorized = () => db.dcaPlans().some((p) => p.id === plan.id && p.enabled && + p.mint === intent.mint && p.buySol === intent.buySol && p.intervalMinutes === intent.intervalMinutes && p.roundsTotal === intent.roundsTotal); const wallets = services.selectWallets(); const settings = db.settings(); // The store mutates plan in place, so keep the previous count by value. @@ -573,10 +617,15 @@ export async function runDueDca( try { const addresses = wallets.map((w) => w.address); - const heldBefore = await services.getMintBalances(addresses, plan.mint).catch(() => undefined); + const accountAddresses = [...new Set([...addresses, ...(services.allWallets?.() ?? []).map((w) => w.address)])]; + const heldBefore = await services.getMintBalances(accountAddresses, plan.mint).catch(() => undefined); + const decimals = await services.getMintDecimals?.(plan.mint).catch(() => undefined); + + assertExecutionCurrent(); + if (!db.dcaPlans().some((p) => p.id === plan.id && p.enabled)) continue; tradeStarted = true; - const summary = await services.batchPumpTrade(wallets, { + const summary = await withExecution(() => services.batchPumpTrade(wallets, { action: 'buy', mint: plan.mint, amount: plan.buySol, @@ -584,12 +633,16 @@ export async function runDueDca( slippagePercent: settings.slippagePercent, priorityFeeSol: settings.priorityFeeSol, pool: 'auto', - }); + }), authorized); - const fills = summary.results.filter((r) => r.ok && r.signature).length; + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; confirmedFills = fills; const confirmationUnknown = summary.results.some((r) => r.confirmationUnknown); - if (confirmationUnknown) db.updateDcaPlan(plan.id, { enabled: false }); + if (confirmationUnknown) { + db.updateDcaPlan(plan.id, { enabled: false }); + db.invalidateBasis(plan.mint); + } if (fills === 0) { if (confirmationUnknown) { @@ -610,7 +663,7 @@ export async function runDueDca( continue; } - const gained = await services.measureTokensGained(addresses, plan.mint, heldBefore, undefined); + const gained = decimals === undefined ? 0 : await services.measureTokensGained(filled.map((r) => r.address), plan.mint, heldBefore, decimals); db.recordBuy(plan.mint, { solSpent: plan.buySol * fills, fills, @@ -618,6 +671,8 @@ export async function runDueDca( symbol: plan.symbol, costSol: summary.solSpent, freshEntry: isFreshEntry(heldBefore), + decimals, + quantityComplete: !confirmationUnknown, }); db.appendTradeLog({ at: Date.now(), @@ -636,7 +691,7 @@ export async function runDueDca( `Bought ${plan.buySol} SOL × ${wallets.length} wallets`, `✅ ${summary.succeeded} ❌ ${summary.failed}`, confirmationUnknown - ? '\nSome trades may still land. The plan is paused; check the wallets before resuming it.' + ? '\nSome trades may still land. Entry basis is unknown and the plan is paused; check the wallets before resuming it.' : done ? '\nPlan complete.' : `\nNext round in ${plan.intervalMinutes} minutes.`, ].join('\n'), ).catch(() => {}); diff --git a/src/store/db.ts b/src/store/db.ts index ea39eb5..df504e8 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -84,15 +84,17 @@ export interface PositionRecord { * thousandth, the lifetime ratio reports the old price, and every rule built * on it fires at a number from a trade that is over. * - * Reset when a buy lands on a coin the wallets were holding none of. A - * partial sale leaves it alone, which is correct: selling half a position - * does not change what the other half cost. + * Reset only when a complete account-wide read establishes no holdings. + * A measured sale retires the proportional basis; its per-token price stays + * the same until another buy adds to the remaining position. * * Absent on positions recorded before it existed; readers fall back to the * lifetime ratio, which is what they used to use. */ basisSol?: number; basisTokens?: number; + /** False when any open-position quantity was unmeasured; never guess entry. */ + basisKnown?: boolean; /** Mint decimals, kept so an exit can turn raw balance deltas into tokens. */ decimals?: number; firstBuyAt: number; @@ -218,6 +220,8 @@ export interface CopyTarget { enabled: boolean; /** Newest signature already processed, so a restart does not replay history. */ lastSignature?: string; + /** Recent transaction receipts claimed for this target, including socket deliveries. */ + handledSignatures?: string[]; /** Mints already copied from this target. */ copiedMints: string[]; /** Copied buys so far per mint, for the `every` cap. */ @@ -284,6 +288,8 @@ export interface BuyEntry { /** True when the wallets held none of this coin before the batch. */ freshEntry?: boolean; decimals?: number; + /** False if another submitted wallet fill still has an unknown outcome. */ + quantityComplete?: boolean; } /** @@ -641,8 +647,11 @@ export const db = { /** Add a completed buy to the position's cost basis. */ recordBuy(mint: string, entry: BuyEntry): void { - const { solSpent, fills, tokensBought = 0, symbol, costSol, freshEntry = false, decimals } = entry; - if (solSpent <= 0 || fills <= 0) return; + const { solSpent, fills, tokensBought = 0, symbol, costSol, freshEntry = false, decimals, quantityComplete = true } = entry; + if (!Number.isFinite(solSpent) || solSpent <= 0 || !Number.isSafeInteger(fills) || fills <= 0 || + !Number.isFinite(tokensBought) || tokensBought < 0 || + (costSol !== undefined && (!Number.isFinite(costSol) || costSol < 0)) || + (decimals !== undefined && (!Number.isInteger(decimals) || decimals < 0 || decimals > 255))) return; const d = load(); const now = Date.now(); const pos = d.positions[mint] ?? { @@ -657,21 +666,34 @@ export const db = { lastTradeAt: now, }; + const priorBasisSol = pos.basisSol ?? pos.investedSol; + const priorBasisTokens = pos.basisTokens ?? pos.tokensBought; + // Old sales never recorded their quantity, so their remaining basis cannot + // be reconstructed from lifetime buys. A later fresh entry can recover it. + const priorKnown = pos.basisKnown ?? (pos.sellFills === 0 && + (priorBasisSol === 0 || priorBasisTokens > 0)); + const nextCost = (pos.costSol ?? pos.investedSol) + (costSol ?? solSpent); + const nextInvested = pos.investedSol + solSpent; + const nextTokens = pos.tokensBought + tokensBought; + const nextBasisSol = Math.max(0, freshEntry ? 0 : priorBasisSol) + solSpent; + const nextBasisTokens = Math.max(0, freshEntry ? 0 : priorBasisTokens) + tokensBought; + if (![nextCost, nextInvested, nextTokens, nextBasisSol, nextBasisTokens].every(Number.isFinite) || + !Number.isSafeInteger(pos.buyFills + fills)) return; + // a batch whose true cost went unmeasured contributes its notional, so the // running total stays comparable rather than developing a hole — and a // position that predates the measurement starts from what it was recorded // as having spent rather than from zero - pos.costSol = (pos.costSol ?? pos.investedSol) + (costSol ?? solSpent); - pos.investedSol += solSpent; + pos.costSol = nextCost; + pos.investedSol = nextInvested; pos.buyFills += fills; - pos.tokensBought += tokensBought; + pos.tokensBought = nextTokens; // a buy into a coin the wallets held none of starts the basis over; one // into a position already open adds to it - const priorBasisSol = freshEntry ? 0 : (pos.basisSol ?? pos.investedSol - solSpent); - const priorBasisTokens = freshEntry ? 0 : (pos.basisTokens ?? pos.tokensBought - tokensBought); - pos.basisSol = Math.max(0, priorBasisSol) + solSpent; - pos.basisTokens = Math.max(0, priorBasisTokens) + tokensBought; + pos.basisSol = nextBasisSol; + pos.basisTokens = nextBasisTokens; + pos.basisKnown = quantityComplete && (freshEntry || priorKnown) && tokensBought > 0; pos.lastTradeAt = now; if (symbol && !pos.symbol) pos.symbol = symbol; @@ -682,8 +704,9 @@ export const db = { }, /** Add sell proceeds. Positions with no recorded buy are still tracked. */ - recordSell(mint: string, solReceived: number, fills: number): void { - if (solReceived <= 0 || fills <= 0) return; + recordSell(mint: string, solReceived: number, fills: number, tokensSold?: number): void { + if (!Number.isFinite(solReceived) || solReceived < 0 || !Number.isSafeInteger(fills) || fills <= 0 || + (tokensSold !== undefined && (!Number.isFinite(tokensSold) || tokensSold < 0))) return; const d = load(); const now = Date.now(); const pos = d.positions[mint] ?? { @@ -697,6 +720,21 @@ export const db = { lastTradeAt: now, }; + const basisSol = pos.basisSol ?? pos.investedSol; + const basisTokens = pos.basisTokens ?? pos.tokensBought; + if (!Number.isFinite(pos.realisedSol + solReceived) || !Number.isSafeInteger(pos.sellFills + fills)) return; + const known = pos.basisKnown ?? (pos.sellFills === 0 && basisSol > 0 && basisTokens > 0); + if (known && tokensSold !== undefined && Number.isFinite(tokensSold) && tokensSold > 0 && + tokensSold <= basisTokens * (1 + 1e-9)) { + const remaining = Math.max(0, basisTokens - tokensSold); + pos.basisSol = basisTokens > 0 ? basisSol * (remaining / basisTokens) : 0; + pos.basisTokens = remaining; + pos.basisKnown = true; + } else { + pos.basisKnown = false; + delete pos.basisSol; + delete pos.basisTokens; + } pos.realisedSol += solReceived; pos.sellFills += fills; pos.lastTradeAt = now; @@ -705,6 +743,16 @@ export const db = { flush(); }, + /** Uncertain fills may change holdings even when no fill can yet be booked. */ + invalidateBasis(mint: string): void { + const pos = load().positions[mint]; + if (!pos) return; + pos.basisKnown = false; + delete pos.basisSol; + delete pos.basisTokens; + flush(); + }, + copyDecisions(limit = 12): CopyDecision[] { return load().copyDecisions.slice(0, limit); }, @@ -756,6 +804,11 @@ export const db = { const pos = d.positions[mint]; if (!pos || !Number.isFinite(sol) || sol < 0) return; pos.realisedSol = sol; + // A recovered historical return does not establish how many tokens sold. + // Keep lifetime profit, but require a measured fresh entry for new rules. + pos.basisKnown = false; + delete pos.basisSol; + delete pos.basisTokens; flush(); }, diff --git a/src/trade/engine.ts b/src/trade/engine.ts index dc0564b..37ce300 100644 --- a/src/trade/engine.ts +++ b/src/trade/engine.ts @@ -11,6 +11,8 @@ import { sendSplToken, getSplBalances, getTokenBalance, + getTokenAccounts, + getMintDecimals, getMintBalances, recentPriorityFeeMicroLamports, priorityFeeSolFromMicroLamports, @@ -22,6 +24,7 @@ import { detectPool, PUMP_PROGRAM_ID } from './curve.js'; import { swapToSol, swapFromSol } from './jupiter.js'; import { fundingBalances, partitionByBalance, requiredForBuy, exitReserveLamports } from './fund.js'; import { TransactionRejectedError, TransactionSubmissionUnknownError } from './errors.js'; +import { withExecution } from '../services/execution.js'; /** * The multiplier an exit is allowed to bid, mirrored from the watcher. @@ -75,16 +78,16 @@ export async function measureTokensGained( before: Map | undefined, decimals: number | undefined, ): Promise { - if (!before || addresses.length === 0) return 0; + if (!before || addresses.length === 0 || decimals === undefined || !Number.isInteger(decimals) || decimals < 0) return 0; const after = await getMintBalances(addresses, mint).catch(() => undefined); if (!after) return 0; let deltaRaw = 0n; - for (const [address, held] of after) deltaRaw += held - (before.get(address) ?? 0n); + for (const address of new Set(addresses)) deltaRaw += (after.get(address) ?? 0n) - (before.get(address) ?? 0n); if (deltaRaw <= 0n) return 0; - return Number(deltaRaw) / 10 ** (decimals ?? 6); + return Number(deltaRaw) / 10 ** decimals; } /** @@ -113,16 +116,16 @@ export async function measureTokensSold( before: Map | undefined, decimals: number | undefined, ): Promise { - if (!before || addresses.length === 0) return 0; + if (!before || addresses.length === 0 || decimals === undefined || !Number.isInteger(decimals) || decimals < 0) return 0; const after = await getMintBalances(addresses, mint).catch(() => undefined); if (!after) return 0; let deltaRaw = 0n; - for (const [address, held] of before) deltaRaw += held - (after.get(address) ?? 0n); + for (const address of new Set(addresses)) deltaRaw += (before.get(address) ?? 0n) - (after.get(address) ?? 0n); if (deltaRaw <= 0n) return 0; - return Number(deltaRaw) / 10 ** (decimals ?? 6); + return Number(deltaRaw) / 10 ** decimals; } function fail(w: WalletRecord, err: unknown): ExecutionResult { @@ -158,6 +161,15 @@ export async function batchPumpTrade( request: TradeRequest, mode: ExecutionMode = db.settings().executionMode, onProgress?: ProgressFn, +): Promise { + return withExecution(() => batchPumpTradeLocked(wallets, request, mode, onProgress)); +} + +async function batchPumpTradeLocked( + wallets: WalletRecord[], + request: TradeRequest, + mode: ExecutionMode, + onProgress?: ProgressFn, ): Promise { const startedAt = Date.now(); const solWallets = wallets.filter((w) => !w.disabled); @@ -302,6 +314,12 @@ export async function batchPumpTrade( : await parallelTrades(active, req, startedAt, ctx, onProgress); const combined = summarise([...summary.results, ...idle], startedAt); + // An unresolved wallet can land inside the measurement window. Assigning its + // spend/proceeds to confirmed fills would invent the confirmed position's cost. + if (combined.results.some((r) => r.confirmationUnknown)) { + db.invalidateBasis(req.mint); + return combined; + } if (req.action === 'buy') { combined.solSpent = await measureSolSpent(active.map((w) => w.address), solBefore); } else if (solBeforeSell) { @@ -658,6 +676,14 @@ export async function batchSweepSol( wallets: WalletRecord[], destination: string, onProgress?: ProgressFn, +): Promise { + return withExecution(() => batchSweepSolLocked(wallets, destination, onProgress)); +} + +async function batchSweepSolLocked( + wallets: WalletRecord[], + destination: string, + onProgress?: ProgressFn, ): Promise { const startedAt = Date.now(); const settings = db.settings(); @@ -684,8 +710,8 @@ export async function batchSweepSol( * An empty wallet keeps the configured figure, which is what the setting * is for. */ - const holdings = await getSplBalances(w.address).catch(() => []); - const holdsTokens = holdings.some((t) => t.amount > 0); + const holdings = await getSplBalances(w.address).catch(() => undefined); + const holdsTokens = holdings === undefined || holdings.some((t) => t.rawAmount > 0n); const floorSol = Number( exitReserveLamports( @@ -733,54 +759,62 @@ export async function batchSweepToken( mint: string, destination: string, onProgress?: ProgressFn, +): Promise { + return withExecution(() => batchSweepTokenLocked(wallets, mint, destination, onProgress)); +} + +async function batchSweepTokenLocked( + wallets: WalletRecord[], + mint: string, + destination: string, + onProgress?: ProgressFn, ): Promise { const startedAt = Date.now(); const settings = db.settings(); const senders = wallets.filter((w) => !w.disabled && w.address !== destination); let done = 0; - const results = await pMap(senders, config.trading.concurrency, async (w) => { + const perWallet = await pMap(senders, config.trading.concurrency, async (w): Promise => { try { - const holding = await getTokenBalance(w.address, mint); - if (!holding || holding.rawAmount === 0n) { - return { + const holdings = await getTokenAccounts(w.address, mint); + if (holdings.length === 0) { + return [{ walletId: w.id, label: w.label, address: w.address, ok: true, detail: 'no balance', - } satisfies ExecutionResult; + } satisfies ExecutionResult]; } - const signature = await sendSplToken( - solanaKeypair(w), - destination, - mint, - holding.rawAmount, - holding.decimals, - settings.priorityFeeSol, - holding.programId, - true, // close the emptied account and reclaim its rent - holding.tokenAccount, - ); - - return { - walletId: w.id, - label: w.label, - address: w.address, - ok: true, - signature, - detail: `${holding.amount} tokens`, - } satisfies ExecutionResult; + const results: ExecutionResult[] = []; + const keypair = solanaKeypair(w); + // Each account has its own source and rent. Preserve already confirmed + // transfers if a later account fails, instead of hiding the partial fill. + for (const holding of holdings) { + try { + const signature = await sendSplToken( + keypair, destination, mint, holding.rawAmount, holding.decimals, + settings.priorityFeeSol, holding.programId, true, holding.tokenAccount, + ); + results.push({ walletId: w.id, label: w.label, address: w.address, ok: true, + signature, detail: `${holding.amount} tokens from ${holding.tokenAccount}` }); + } catch (err) { + results.push(fail(w, err)); + // Unknown confirmation can leave this wallet's available SOL unclear. + if (err instanceof TransactionSubmissionUnknownError) break; + } + } + return results; } catch (err) { - return fail(w, err); + return [fail(w, err)]; } finally { done++; await onProgress?.(done, senders.length); } }); - return summarise(results, startedAt); + return summarise(perWallet.flat(), startedAt); } /** @@ -790,6 +824,13 @@ export async function batchSweepToken( export async function batchSellAllPositions( wallets: WalletRecord[], onProgress?: ProgressFn, +): Promise<{ mints: string[]; summaries: Record; skipped: string[] }> { + return withExecution(() => batchSellAllPositionsLocked(wallets, onProgress)); +} + +async function batchSellAllPositionsLocked( + wallets: WalletRecord[], + onProgress?: ProgressFn, ): Promise<{ mints: string[]; summaries: Record; skipped: string[] }> { const settings = db.settings(); @@ -832,6 +873,9 @@ export async function batchSellAllPositions( await onProgress?.(i, mints.length, `selling ${mint.slice(0, 6)}…`); const holders = wallets.filter((w) => !w.disabled); + const addresses = holders.map((w) => w.address); + const before = await getMintBalances(addresses, mint).catch(() => undefined); + const decimals = db.position(mint)?.decimals ?? await getMintDecimals(mint).catch(() => undefined); const summary = await batchPumpTrade(holders, { action: 'sell', mint, @@ -844,9 +888,12 @@ export async function batchSellAllPositions( summaries[mint] = summary; // closing everything is still a set of exits, and each one returned SOL - const fills = summary.results.filter((r) => r.ok && r.signature).length; - if (summary.solReceived !== undefined && fills > 0) { - db.recordSell(mint, summary.solReceived, fills); + const filled = summary.results.filter((r) => r.ok && r.signature); + const fills = filled.length; + const uncertain = summary.results.some((r) => r.confirmationUnknown); + if (fills > 0) { + const sold = await measureTokensSold(filled.map((r) => r.address), mint, before, decimals); + db.recordSell(mint, summary.solReceived ?? 0, fills, uncertain ? undefined : sold || undefined); } } diff --git a/src/trade/fund.ts b/src/trade/fund.ts index 2d2bd70..4716fb8 100644 --- a/src/trade/fund.ts +++ b/src/trade/fund.ts @@ -12,6 +12,7 @@ import { log } from '../logger.js'; import { TransactionRejectedError, TransactionSubmissionUnknownError } from './errors.js'; import type { WalletRecord, ExecutionResult, BatchSummary } from '../types.js'; import type { ProgressFn } from './engine.js'; +import { withExecution } from '../services/execution.js'; /** * Distribution — the opposite direction to a sweep. @@ -130,6 +131,15 @@ export async function executeFunding( plan: FundPlan, priorityFeeSol: number, onProgress?: ProgressFn, +): Promise { + return withExecution(() => executeFundingLocked(source, plan, priorityFeeSol, onProgress)); +} + +async function executeFundingLocked( + source: WalletRecord, + plan: FundPlan, + priorityFeeSol: number, + onProgress?: ProgressFn, ): Promise { const startedAt = Date.now(); const results: ExecutionResult[] = []; diff --git a/src/trade/jito.ts b/src/trade/jito.ts index 00916d4..fcece1b 100644 --- a/src/trade/jito.ts +++ b/src/trade/jito.ts @@ -3,6 +3,7 @@ import bs58 from 'bs58'; import { endpoints } from '../config.js'; import { fetchJson, sleep } from '../util.js'; import { TransactionRejectedError, TransactionSubmissionUnknownError } from './errors.js'; +import { assertExecutionCurrent } from '../services/execution.js'; /** * Jito bundle submission. A bundle is an ordered list of up to 5 transactions @@ -25,6 +26,9 @@ export async function sendBundle(transactions: VersionedTransaction[]): Promise< const encoded = transactions.map((tx) => bs58.encode(tx.serialize())); const firstSignature = bs58.encode(transactions[0]!.signatures[0]!); + // Cancellation before dispatch is definite; it must not be labelled an + // uncertain submission or retried with a wallet that has been removed. + assertExecutionCurrent(); let res: JitoRpcResponse; try { res = await fetchJson>(endpoints.jitoBundles, { diff --git a/src/trade/jupiter.ts b/src/trade/jupiter.ts index 7098556..98afa69 100644 --- a/src/trade/jupiter.ts +++ b/src/trade/jupiter.ts @@ -1,7 +1,8 @@ import { VersionedTransaction, Keypair } from '@solana/web3.js'; import { endpoints } from '../config.js'; -import { fetchJson } from '../util.js'; +import { fetchJupiterJson } from '../services/jupiter-client.js'; import { LAMPORTS, WSOL_MINT, sendAndConfirm } from '../chains/solana.js'; +import { assertExternalTrade, ExternalTransactionValidationError, jupiterSwapVariants, priorityFeeLamports, signExternalTransaction } from './validation.js'; /** * Jupiter aggregator. Used for anything that is not a live pump.fun curve — @@ -17,26 +18,77 @@ export interface JupQuote { priceImpactPct: string; routePlan: unknown[]; slippageBps: number; + swapMode: 'ExactIn'; + platformFee?: { amount: string; feeBps: number } | null; } -export async function getQuote(params: { +interface QuoteParams { inputMint: string; outputMint: string; /** Raw amount in the input mint's smallest unit. */ amount: bigint; slippageBps: number; onlyDirectRoutes?: boolean; -}): Promise { +} + +const U64_MAX = (1n << 64n) - 1n; + +function quoteFailure(reason: string): never { + throw new ExternalTransactionValidationError(`Jupiter quote ${reason}`); +} + +function positiveQuoteAmount(value: unknown, field: string): bigint { + if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) quoteFailure(`has an invalid ${field}.`); + const amount = BigInt(value); + if (amount > U64_MAX) quoteFailure(`has an out-of-range ${field}.`); + return amount; +} + +/** Bind a provider response to the exact input trade before requesting a swap. */ +export function validateQuote(value: unknown, params: QuoteParams): JupQuote { + if (params.amount <= 0n || params.amount > U64_MAX) quoteFailure('input must be a positive u64 amount.'); + if (!Number.isInteger(params.slippageBps) || params.slippageBps < 0 || params.slippageBps >= 10_000) { + quoteFailure('slippage must be an integer from 0 to 9999 basis points.'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) quoteFailure('is not an object.'); + const q = value as JupQuote; + if (q.inputMint !== params.inputMint || q.outputMint !== params.outputMint) quoteFailure('mints do not match the request.'); + if (q.swapMode !== 'ExactIn') quoteFailure('must use ExactIn.'); + if (positiveQuoteAmount(q.inAmount, 'inAmount') !== params.amount) quoteFailure('input amount does not match the request.'); + if (q.slippageBps !== params.slippageBps) quoteFailure('slippage does not match the request.'); + const output = positiveQuoteAmount(q.outAmount, 'outAmount'); + const threshold = positiveQuoteAmount(q.otherAmountThreshold, 'otherAmountThreshold'); + const minimum = (output * BigInt(10_000 - params.slippageBps)) / 10_000n; + if (threshold > output || threshold < minimum) quoteFailure('output threshold does not honor the requested slippage.'); + if (!Array.isArray(q.routePlan) || q.routePlan.length === 0) quoteFailure('has no route.'); + if (typeof q.priceImpactPct !== 'string' || !Number.isFinite(Number(q.priceImpactPct)) || Number(q.priceImpactPct) < 0) { + quoteFailure('has an invalid price impact.'); + } + // This bot never requests an integrator fee. Preserve the rest of Jupiter's + // response for the builder, but reject an unexpected platform fee. + if (q.platformFee != null && (q.platformFee.amount !== '0' || q.platformFee.feeBps !== 0)) { + quoteFailure('contains an unexpected platform fee.'); + } + return q; +} + +export async function getQuote(params: QuoteParams): Promise { + if (params.amount <= 0n || params.amount > U64_MAX) quoteFailure('input must be a positive u64 amount.'); + if (!Number.isInteger(params.slippageBps) || params.slippageBps < 0 || params.slippageBps >= 10_000) { + quoteFailure('slippage must be an integer from 0 to 9999 basis points.'); + } const qs = new URLSearchParams({ inputMint: params.inputMint, outputMint: params.outputMint, amount: params.amount.toString(), slippageBps: String(params.slippageBps), + swapMode: 'ExactIn', restrictIntermediateTokens: 'true', }); if (params.onlyDirectRoutes) qs.set('onlyDirectRoutes', 'true'); - return fetchJson(`${endpoints.jupiterQuote}?${qs}`, { timeoutMs: 20_000 }); + const quote = await fetchJupiterJson(`${endpoints.jupiterQuote}?${qs}`, { timeoutMs: 20_000 }); + return validateQuote(quote, params); } export async function buildSwap( @@ -44,7 +96,16 @@ export async function buildSwap( userPublicKey: string, priorityFeeSol: number, ): Promise { - const res = await fetchJson<{ swapTransaction: string }>(endpoints.jupiterSwap, { + // Validate again for direct callers; getQuote additionally binds these fields + // to the original user request rather than to the quote itself. + validateQuote(quote, { + inputMint: quote.inputMint, + outputMint: quote.outputMint, + amount: positiveQuoteAmount(quote.inAmount, 'inAmount'), + slippageBps: quote.slippageBps, + }); + const feeLamports = priorityFeeLamports(priorityFeeSol); + const res = await fetchJupiterJson<{ swapTransaction: string }>(endpoints.jupiterSwap, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -53,17 +114,27 @@ export async function buildSwap( // pump tokens are traded against SOL, so let Jupiter handle the wrapping wrapAndUnwrapSol: true, dynamicComputeUnitLimit: true, - prioritizationFeeLamports: Math.floor(priorityFeeSol * LAMPORTS), + prioritizationFeeLamports: Number(feeLamports), }), timeoutMs: 25_000, }); - return VersionedTransaction.deserialize(Buffer.from(res.swapTransaction, 'base64')); + if (typeof res?.swapTransaction !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(res.swapTransaction)) { + throw new ExternalTransactionValidationError('Jupiter returned an invalid serialized transaction.'); + } + const tx = VersionedTransaction.deserialize(Buffer.from(res.swapTransaction, 'base64')); + assertExternalTrade(tx, { + wallet: userPublicKey, + priorityFeeSol, + swaps: jupiterSwapVariants, + mints: [quote.inputMint, quote.outputMint], + wrappedSolLamports: quote.inputMint === WSOL_MINT ? BigInt(quote.inAmount) : 0n, + }); + return tx; } export function signSwap(tx: VersionedTransaction, signer: Keypair): VersionedTransaction { - tx.sign([signer]); - return tx; + return signExternalTransaction(tx, signer); } /** diff --git a/src/trade/pumpportal.ts b/src/trade/pumpportal.ts index 60df888..b14feb6 100644 --- a/src/trade/pumpportal.ts +++ b/src/trade/pumpportal.ts @@ -3,14 +3,16 @@ import bs58 from 'bs58'; import { endpoints } from '../config.js'; import { fetchBytes, fetchJson } from '../util.js'; import type { TradeRequest } from '../types.js'; +import { assertExternalTrade, priorityFeeLamports, pumpSwapVariants, signExternalTransaction } from './validation.js'; +import { WSOL_MINT } from '../chains/solana.js'; /** * pump.fun execution via PumpPortal's *local* trading API. * - * The important property: PumpPortal only builds an unsigned transaction and - * hands it back. Signing happens here, with keys that never leave this process, - * so no third party can move funds. It also means the pump.fun program layout — - * which changes without notice — stays their problem rather than ours. + * PumpPortal builds a message and local signing authorizes that whole message. + * Keeping keys local does not make an external builder trustworthy. The bounded + * checks below reject unexpected signers, fees and missing swaps; full validation + * of spend amounts, token accounts and recipients still needs venue decoders. */ export interface TradeArgs { @@ -25,6 +27,16 @@ export interface TradeArgs { pool: TradeRequest['pool']; } +function wrappedSolBudget(args: TradeArgs): bigint { + if (args.action !== 'buy' || args.denominatedInSol !== 'true' || typeof args.amount !== 'number') return 0n; + if (!Number.isFinite(args.slippage) || args.slippage < 0 || args.slippage >= 100) throw new Error('Invalid PumpPortal slippage.'); + // Exact-token buys may wrap the maximum permitted SOL input. Keep this bound + // on top-level wrapping separate from the unresolved swap's actual CPI debit. + const basisPoints = BigInt(Math.ceil(args.slippage * 100)); + const input = priorityFeeLamports(args.amount); + return (input * (10_000n + basisPoints) + 9_999n) / 10_000n; +} + export function toTradeArgs(req: TradeRequest, publicKey: string): TradeArgs { return { publicKey, @@ -54,7 +66,15 @@ export async function buildTrade(args: TradeArgs): Promise throw new Error(`PumpPortal returned an unexpectedly small response: ${new TextDecoder().decode(bytes)}`); } - return VersionedTransaction.deserialize(bytes); + const tx = VersionedTransaction.deserialize(bytes); + assertExternalTrade(tx, { + wallet: args.publicKey, + priorityFeeSol: args.priorityFee, + swaps: pumpSwapVariants(args.pool, args.action), + mints: [WSOL_MINT, args.mint], + wrappedSolLamports: wrappedSolBudget(args), + }); + return tx; } /** @@ -76,14 +96,27 @@ export async function buildTradeBundle(argsList: TradeArgs[]): Promise VersionedTransaction.deserialize(bs58.decode(b58))); + if (encoded.length !== argsList.length) { + throw new Error(`PumpPortal returned ${encoded.length} transactions for ${argsList.length} wallets.`); + } + return encoded.map((b58, index) => { + if (typeof b58 !== 'string') throw new Error('PumpPortal returned an invalid encoded transaction.'); + const tx = VersionedTransaction.deserialize(bs58.decode(b58)); + const args = argsList[index]!; + // Bundle priorityFee on the first request is used as its Jito tip. A + // zero compute price is valid; the tip remains part of the signed message. + assertExternalTrade(tx, { + wallet: args.publicKey, + priorityFeeSol: args.priorityFee, + swaps: pumpSwapVariants(args.pool, args.action), + mints: [WSOL_MINT, args.mint], + wrappedSolLamports: wrappedSolBudget(args), + jitoTipLamports: index === 0 ? priorityFeeLamports(args.priorityFee) : 0n, + }); + return tx; + }); } export function signTx(tx: VersionedTransaction, signer: Keypair): VersionedTransaction { - // Re-wrapping the message drops any placeholder signatures PumpPortal left in - // place, so the only signature on the wire is the one we just produced. - const signed = new VersionedTransaction(tx.message); - signed.sign([signer]); - return signed; + return signExternalTransaction(tx, signer); } diff --git a/src/trade/validation.ts b/src/trade/validation.ts new file mode 100644 index 0000000..7a7e50a --- /dev/null +++ b/src/trade/validation.ts @@ -0,0 +1,248 @@ +import { createHash } from 'node:crypto'; +import { ComputeBudgetProgram, Keypair, PublicKey, SystemProgram, VersionedTransaction } from '@solana/web3.js'; +import { getAssociatedTokenAddressSync, NATIVE_MINT, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import type { TradeRequest } from '../types.js'; + +const MAX_COMPUTE_UNITS = 1_400_000; +const MICRO_LAMPORTS = 1_000_000n; +const LAMPORTS_PER_SOL = 1_000_000_000; + +/** A local refusal, before signing or broadcasting an external message. */ +export class ExternalTransactionValidationError extends Error { + constructor(reason: string) { + super(`External transaction refused: ${reason}`); + this.name = 'ExternalTransactionValidationError'; + } +} + +function refuse(reason: string): never { + throw new ExternalTransactionValidationError(reason); +} + +/** This bot does not request sponsored transactions or additional signers. */ +export function assertWalletSigner(tx: VersionedTransaction, wallet: PublicKey): void { + const { header, staticAccountKeys } = tx.message; + if (header.numRequiredSignatures !== 1 || tx.signatures.length !== 1) { + refuse('the wallet must be the only required signer.'); + } + if (!staticAccountKeys[0]?.equals(wallet) || header.numReadonlySignedAccounts !== 0) { + refuse('the requested wallet must be the writable fee payer.'); + } + if (header.numReadonlyUnsignedAccounts > staticAccountKeys.length - 1) { + refuse('invalid account permissions.'); + } +} + +/** Never preserve signatures supplied by a transaction builder. */ +export function signExternalTransaction(tx: VersionedTransaction, signer: Keypair): VersionedTransaction { + assertWalletSigner(tx, signer.publicKey); + const policy = validatedTrades.get(tx); + if (!policy) refuse('the message has not passed the external builder checks.'); + // Recheck immediately before signing in case a caller changed the message. + assertExternalTrade(tx, policy); + const signed = new VersionedTransaction(tx.message); + signed.sign([signer]); + return signed; +} + +export function priorityFeeLamports(priorityFeeSol: number): bigint { + const lamports = Math.floor(priorityFeeSol * LAMPORTS_PER_SOL); + if (!Number.isFinite(priorityFeeSol) || priorityFeeSol < 0 || !Number.isSafeInteger(lamports)) { + refuse('priority fee must be a non-negative finite amount in the supported range.'); + } + return BigInt(lamports); +} + +function anchor(name: string): string { + return createHash('sha256').update(`global:${name}`).digest().subarray(0, 8).toString('hex'); +} + +interface SwapVariant { + program: string; + prefixes: string[]; + minBytes: number; +} + +// Presence checks only. They do not decode route accounts, amounts or recipients. +// Primary sources: pump-fun/pump-public-docs/idl/{pump,pump_amm}.json; +// docs.raydium.io/reference/program-addresses and products/amm-v4/instructions; +// raydium-io/{raydium-cp-swap,raydium-clmm}/programs/*/src/lib.rs; +// raydium-io/raydium-sdk-V2/src/raydium/launchpad/instrument.ts. +const PUMP = '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P'; +const PUMP_AMM = 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA'; +const RAYDIUM_AMM = '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8'; +const RAYDIUM_CPMM = 'CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C'; +const RAYDIUM_CLMM = 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'; +const LAUNCHLAB = 'LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj'; +export const JUPITER_PROGRAM = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +// Observed in the unsigned response of PumpPortal's documented trade-local API +// on 2026-10-02 (buy/sell, documentation example mint). No published IDL/source +// was found. Retain builder trust for this opaque wrapper for compatibility; +// its presence identifies a current builder envelope, not action/spend intent. +const PUMPPORTAL_WRAPPER = 'FAdo9NCw1ssek6Z6yeWzWjhLVsr8uiCwcWNUnKgzTnHe'; + +// Published constant accounts: docs.jito.wtf/lowlatencytxnsend/#gettipaccounts +export const JITO_TIP_ACCOUNTS = new Set([ + '96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5', + 'HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe', + 'Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY', + 'ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49', + 'DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh', + 'ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt', + 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + '3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT', +]); + +function anchorVariant(program: string, names: string[], minBytes = 24): SwapVariant { + return { program, prefixes: names.map(anchor), minBytes }; +} + +export function pumpSwapVariants(pool: TradeRequest['pool'], action: 'buy' | 'sell'): SwapVariant[] { + const pump = anchorVariant(PUMP, action === 'buy' ? ['buy', 'buy_exact_sol_in', 'buy_v2', 'buy_exact_quote_in_v2'] : ['sell', 'sell_v2']); + const amm = anchorVariant(PUMP_AMM, action === 'buy' ? ['buy', 'buy_exact_quote_in', 'buy_v2', 'buy_exact_quote_in_v2'] : ['sell', 'sell_v2']); + // AMM v4 uses a one-byte tag followed by two u64 amounts (9 / 11). + const raydium = { program: RAYDIUM_AMM, prefixes: ['09', '0b', '10', '11'], minBytes: 17 }; + const cpmm = anchorVariant(RAYDIUM_CPMM, ['swap_base_input', 'swap_base_output']); + const clmm = anchorVariant(RAYDIUM_CLMM, ['swap', 'swap_v2', 'swap_router_base_in']); + const launch = anchorVariant(LAUNCHLAB, action === 'buy' ? ['buy_exact_in', 'buy_exact_out'] : ['sell_exact_in', 'sell_exact_out']); + const wrapper = { program: PUMPPORTAL_WRAPPER, prefixes: [''], minBytes: 8 }; + switch (pool) { + case 'pump': return [pump, wrapper]; + case 'pump-amm': return [amm, wrapper]; + case 'raydium': return [raydium, cpmm, clmm, wrapper]; + case 'raydium-cpmm': return [cpmm, wrapper]; + case 'launchlab': + case 'bonk': return [launch, wrapper]; + case 'auto': return [pump, amm, raydium, cpmm, clmm, launch, wrapper]; + } +} + +// Swap V1 requests use the V1 route instruction by default. The V2 variants +// are also published by Jupiter. No token-ledger mode is requested by this bot. +// Source: jup-ag/instruction-parser/src/idl/jupiter.ts and Jupiter's changelog. +export const jupiterSwapVariants: SwapVariant[] = [anchorVariant(JUPITER_PROGRAM, + ['route', 'shared_accounts_route', 'route_v2', 'shared_accounts_route_v2'], 12)]; + +interface TradePolicy { + wallet: string; + priorityFeeSol: number; + swaps: SwapVariant[]; + /** Only these wallet ATAs may be created or closed by top-level instructions. */ + mints: string[]; + /** Maximum direct funding of the wallet's native SOL token account. */ + wrappedSolLamports?: bigint; + /** Allowed only for the first transaction of a requested Jito bundle. */ + jitoTipLamports?: bigint; +} + +const validatedTrades = new WeakMap(); + +/** + * Check identity, message structure, compute fees and a recognizable swap. + * This is deliberately a bounded guard: full intent validation still requires + * decoding every supported venue's accounts/instruction data and resolving ALTs. + * Top-level wallet transfers/authority changes are checked separately below; + * this still does not establish the swap's CPI behavior or full spend intent. + */ +export function assertExternalTrade( + tx: VersionedTransaction, + params: TradePolicy, +): void { + assertWalletSigner(tx, new PublicKey(params.wallet)); + const maxFee = priorityFeeLamports(params.priorityFeeSol); + const keys = tx.message.staticAccountKeys; + const totalAccounts = keys.length + (tx.message.version === 0 ? tx.message.numAccountKeysFromLookups : 0); + let computeUnits = MAX_COMPUTE_UNITS; + let microLamports = 0n; + const seenBudgetTags = new Set(); + let swapFound = false; + const wallet = new PublicKey(params.wallet); + const wrappedSol = getAssociatedTokenAddressSync(NATIVE_MINT, wallet); + const allowedAtas = new Set(params.mints.flatMap((mint) => + [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].map((program) => + getAssociatedTokenAddressSync(new PublicKey(mint), wallet, false, program).toBase58()))); + let wrappedSolFunded = 0n; + let tips = 0n; + + for (const ix of tx.message.compiledInstructions) { + const program = keys[ix.programIdIndex]; + // A loaded program could hide a compute-price instruction. Do not silently + // skip it. Ordinary loaded trade accounts remain supported without RPC reads. + if (!program) refuse('a program ID is unresolved in an address lookup table.'); + if (ix.accountKeyIndexes.some((index) => !Number.isInteger(index) || index < 0 || index >= totalAccounts)) { + refuse('an instruction refers to an invalid account index.'); + } + const data = Buffer.from(ix.data); + const account = (position: number): PublicKey => { + const index = ix.accountKeyIndexes[position]; + const key = index === undefined ? undefined : keys[index]; + if (!key) refuse('a debit account is unresolved in an address lookup table.'); + return key; + }; + if (program.equals(SystemProgram.programId)) { + // Wrapping native SOL and a requested Jito tip are the only top-level + // System transfers this bot asks external builders to include. Refuse + // nonce, assignment and account-creation variants rather than guess. + if (data.length !== 12 || data.readUInt32LE(0) !== 2 || ix.accountKeyIndexes.length !== 2 || !account(0).equals(wallet)) { + refuse('unsupported top-level System instruction.'); + } + const destination = account(1); + const lamports = data.readBigUInt64LE(4); + if (destination.equals(wrappedSol)) { + wrappedSolFunded += lamports; + if (wrappedSolFunded > (params.wrappedSolLamports ?? 0n)) refuse('native SOL funding exceeds the requested input.'); + } else if (JITO_TIP_ACCOUNTS.has(destination.toBase58())) { + tips += lamports; + if (tips > (params.jitoTipLamports ?? 0n)) refuse('Jito tip exceeds the requested bundle tip.'); + } else { + refuse('unexpected direct SOL transfer destination.'); + } + } + if (program.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) { + if (!(data.length === 0 || (data.length === 1 && data[0] === 1)) || + !account(0).equals(wallet) || !account(2).equals(wallet) || !allowedAtas.has(account(1).toBase58())) { + refuse('unexpected associated-token-account operation.'); + } + } + if (program.equals(TOKEN_PROGRAM_ID) || program.equals(TOKEN_2022_PROGRAM_ID)) { + // Actual trade transfers are invoked by the swap program. Top-level + // setup/cleanup only needs SyncNative and CloseAccount for wallet ATAs. + // This rejects direct token transfers, approvals and authority changes. + const sync = data.length === 1 && data[0] === 17 && ix.accountKeyIndexes.length === 1 && account(0).equals(wrappedSol); + const close = data.length === 1 && data[0] === 9 && ix.accountKeyIndexes.length === 3 && + allowedAtas.has(account(0).toBase58()) && account(1).equals(wallet) && account(2).equals(wallet); + if (!sync && !close) refuse('unexpected direct token transfer, authority change or setup instruction.'); + } + if (program.equals(ComputeBudgetProgram.programId)) { + const tag = data[0]; + if (tag === undefined || ![1, 2, 3, 4].includes(tag) || data.length !== (tag === 3 ? 9 : 5) || ix.accountKeyIndexes.length !== 0) { + refuse('unsupported or malformed compute-budget instruction.'); + } + if (seenBudgetTags.has(tag)) refuse('duplicate compute-budget instruction.'); + seenBudgetTags.add(tag); + if (tag === 2) { + computeUnits = data.readUInt32LE(1); + if (computeUnits === 0 || computeUnits > MAX_COMPUTE_UNITS) refuse('invalid compute-unit limit.'); + } + if (tag === 3) microLamports = data.readBigUInt64LE(1); + } + const programId = program.toBase58(); + const isSwap = params.swaps.some((variant) => variant.program === programId && data.length >= variant.minBytes && + variant.prefixes.some((prefix) => data.subarray(0, prefix.length / 2).toString('hex') === prefix)); + if (isSwap) { + swapFound = true; + } + const isSetup = program.equals(ComputeBudgetProgram.programId) || program.equals(SystemProgram.programId) || + program.equals(ASSOCIATED_TOKEN_PROGRAM_ID) || program.equals(TOKEN_PROGRAM_ID) || program.equals(TOKEN_2022_PROGRAM_ID); + if (!isSwap && !isSetup) refuse('unexpected top-level program or unsupported swap instruction.'); + } + + const fee = (BigInt(computeUnits) * microLamports + MICRO_LAMPORTS - 1n) / MICRO_LAMPORTS; + if (fee > maxFee) refuse(`compute priority fee ${fee} lamports exceeds the requested ${maxFee} lamports.`); + if (params.jitoTipLamports !== undefined && fee + tips > maxFee) { + refuse('combined compute fee and explicit Jito tip exceed the requested bundle budget.'); + } + if (!swapFound) refuse('no recognized swap instruction for the requested venue and action.'); + if (tx.serialize().length > 1232) refuse('transaction exceeds Solana\'s packet size.'); + validatedTrades.set(tx, params); +} From 33f73bc3411363ee9cedcb6695a0b0b3899a47d5 Mon Sep 17 00:00:00 2001 From: Wraith <68072890+wraithioner@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:12:38 +0200 Subject: [PATCH 3/4] Keep error reporting safe for unusual thrown values --- scripts/smoke.ts | 12 ++++++++++++ src/util.ts | 12 +++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 7c63240..87430a8 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -265,6 +265,18 @@ ok('non-addresses rejected (including invalid base58 of the right length)'); console.log('\n[6] Formatting + concurrency'); const util = await import('../src/util.js'); +const circularError: Record = {}; +circularError.self = circularError; +const unreadableError = Object.assign(Object.create(null), { toJSON() { throw new Error('serialization failed'); } }); +for (const thrown of [undefined, Symbol('failure'), function failure() {}, null, 1n, circularError, unreadableError]) { + const message = util.errMessage(thrown); + assert.equal(typeof message, 'string'); + assert.doesNotThrow(() => util.escapeHtml(message)); +} +assert.equal(util.errMessage(new Error('RPC failed')), 'RPC failed'); +assert.equal(util.errMessage(''), ''); +assert.equal(util.errMessage(unreadableError), 'Unknown error'); +ok('formatting unusual thrown values cannot cause another error while reporting a failure'); assert.equal(util.shortAddr(mint), 'DezX…B263'); assert.equal(util.fmtUsd(1234.5), '$1,234.50'); diff --git a/src/util.ts b/src/util.ts index 98eddac..fe325e9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -79,12 +79,18 @@ export function escapeHtml(s: string): string { } export function errMessage(err: unknown): string { - if (err instanceof Error) return err.message; - if (typeof err === 'string') return err; try { - return JSON.stringify(err); + if (err instanceof Error) return String(err.message); + if (typeof err === 'string') return err; + const json = JSON.stringify(err); + if (json !== undefined) return json; } catch { + // Thrown values can be circular or expose a failing serialization hook. + } + try { return String(err); + } catch { + return 'Unknown error'; } } From acd0e9d87215f895595084cb1a8607cdbd74666e Mon Sep 17 00:00:00 2001 From: Wraith <68072890+wraithioner@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:04:52 +0200 Subject: [PATCH 4/4] Split trading modules, share accounting, and enforce code quality --- .github/workflows/check.yml | 2 +- CONTRIBUTING.md | 33 + biome.json | 38 + package-lock.json | 164 +++ package.json | 13 +- scripts/accounting-regressions.ts | 415 +++++- scripts/batchsim.ts | 54 +- scripts/behavior-regressions.ts | 31 + scripts/behaviors/copy.ts | 273 ++++ scripts/behaviors/fixtures.ts | 264 ++++ scripts/behaviors/storage.ts | 159 ++ scripts/behaviors/token.ts | 197 +++ scripts/behaviors/ui.ts | 180 +++ scripts/behaviors/watcher.ts | 128 ++ scripts/client-regressions.ts | 243 ++- scripts/concurrency-regressions.ts | 463 ++++-- scripts/copyevents-regressions.ts | 204 ++- scripts/copytrade-regressions.ts | 94 +- scripts/deep-transaction-regressions.ts | 425 +++++- scripts/manual-trade-regressions.ts | 390 +++++ scripts/netcheck.ts | 71 +- scripts/portfolio-regressions.ts | 82 +- scripts/reconcile-deep-regressions.ts | 257 +++- scripts/reconcile-regressions.ts | 96 +- scripts/safety-regressions.ts | 325 +++- scripts/smoke.ts | 1249 ++++++++-------- scripts/trade-accounting-regressions.ts | 325 ++++ scripts/transaction-regressions.ts | 346 +++-- scripts/wallet-regressions.ts | 167 ++- src/bot/handlers/core.ts | 151 +- src/bot/handlers/trade.ts | 1804 +---------------------- src/bot/handlers/trade/automation.ts | 300 ++++ src/bot/handlers/trade/copy-safety.ts | 184 +++ src/bot/handlers/trade/copy.ts | 443 ++++++ src/bot/handlers/trade/funds.ts | 356 +++++ src/bot/handlers/trade/manual.ts | 452 ++++++ src/bot/handlers/trade/progress.ts | 28 + src/bot/handlers/trade/token.ts | 156 ++ src/bot/handlers/wallets.ts | 51 +- src/bot/index.ts | 139 +- src/bot/session.ts | 16 +- src/bot/ui.ts | 225 ++- src/chains/solana.ts | 131 +- src/config.ts | 45 +- src/index.ts | 43 +- src/logger.ts | 8 +- src/services/copytrade.ts | 1516 +------------------ src/services/copytrade/buy.ts | 416 ++++++ src/services/copytrade/events.ts | 154 ++ src/services/copytrade/intake-policy.ts | 46 + src/services/copytrade/intake.ts | 519 +++++++ src/services/copytrade/policy.ts | 95 ++ src/services/copytrade/reporting.ts | 17 + src/services/copytrade/rules.ts | 70 + src/services/copytrade/sell.ts | 210 +++ src/services/copytrade/state.ts | 90 ++ src/services/execution.ts | 33 +- src/services/jupdata.ts | 7 +- src/services/jupiter-client.ts | 96 +- src/services/locks.ts | 32 +- src/services/metadata.ts | 14 +- src/services/mintauth.ts | 45 +- src/services/notifications.ts | 27 + src/services/pnl.ts | 2 +- src/services/portfolio.ts | 37 +- src/services/price.ts | 2 +- src/services/prices.ts | 8 +- src/services/reconcile.ts | 202 ++- src/services/rugcheck.ts | 27 +- src/services/rule-ids.ts | 6 + src/services/safety.ts | 93 +- src/services/tokeninfo.ts | 147 +- src/services/watcher.ts | 267 ++-- src/store/db.ts | 151 +- src/store/vault.ts | 55 +- src/store/wallets.ts | 35 +- src/trade/accounting.ts | 121 ++ src/trade/curve.ts | 3 +- src/trade/engine.ts | 291 ++-- src/trade/errors.ts | 10 +- src/trade/fund.ts | 21 +- src/trade/jito.ts | 34 +- src/trade/jupiter.ts | 62 +- src/trade/pumpportal.ts | 26 +- src/trade/validation.ts | 171 ++- src/util.ts | 26 +- tsconfig.json | 2 +- 87 files changed, 11011 insertions(+), 5395 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 biome.json create mode 100644 scripts/behavior-regressions.ts create mode 100644 scripts/behaviors/copy.ts create mode 100644 scripts/behaviors/fixtures.ts create mode 100644 scripts/behaviors/storage.ts create mode 100644 scripts/behaviors/token.ts create mode 100644 scripts/behaviors/ui.ts create mode 100644 scripts/behaviors/watcher.ts create mode 100644 scripts/manual-trade-regressions.ts create mode 100644 scripts/trade-accounting-regressions.ts create mode 100644 src/bot/handlers/trade/automation.ts create mode 100644 src/bot/handlers/trade/copy-safety.ts create mode 100644 src/bot/handlers/trade/copy.ts create mode 100644 src/bot/handlers/trade/funds.ts create mode 100644 src/bot/handlers/trade/manual.ts create mode 100644 src/bot/handlers/trade/progress.ts create mode 100644 src/bot/handlers/trade/token.ts create mode 100644 src/services/copytrade/buy.ts create mode 100644 src/services/copytrade/events.ts create mode 100644 src/services/copytrade/intake-policy.ts create mode 100644 src/services/copytrade/intake.ts create mode 100644 src/services/copytrade/policy.ts create mode 100644 src/services/copytrade/reporting.ts create mode 100644 src/services/copytrade/rules.ts create mode 100644 src/services/copytrade/sell.ts create mode 100644 src/services/copytrade/state.ts create mode 100644 src/services/notifications.ts create mode 100644 src/services/rule-ids.ts create mode 100644 src/trade/accounting.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1cef2f4..6f9849b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,4 +21,4 @@ jobs: node-version: ${{ matrix.node }} cache: npm - run: npm ci - - run: npm run check + - run: npm run check:ci diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a24be20 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing + +Use a supported Node.js version (CI checks Node 22 and 24), then install the locked dependencies: + +```sh +npm ci +``` + +Before opening a pull request: + +```sh +npm run format +npm run lint +npm run check +``` + +`npm run format` applies consistent formatting. `npm run lint:fix` applies safe lint fixes; +review the diff before committing. `npm run check` checks formatting and lint without changing +files, runs strict TypeScript checking, and runs the offline smoke and regression suites. +CI runs the same checks through `npm run check:ci`, using Biome's CI diagnostics. + +Keep handlers focused on Telegram interaction and services focused on their own domain. +Reuse the shared trade-accounting functions when recording fills. Preserve wallet locking, +confirmation, cancellation, and accounting order when moving code between modules. + +Test observable behavior with fake providers or controlled promises. Tests should tolerate +formatting changes and renamed local variables; avoid inspecting source-code text to establish +runtime safety. Offline checks must not submit transactions or require production credentials. + +Biome is pinned so formatting and lint behavior stays reproducible. Its recommended lint rules +are enforced, with the blanket non-null assertion style rule disabled: TypeScript still uses +`strict` and `noUncheckedIndexedAccess`. Use assertions only where an existing guard or invariant +establishes the value, and keep that guard visible. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..11f8b72 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["src/**", "scripts/**", "*.json", "!package-lock.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "asNeeded" + } + }, + "assist": { + "enabled": false + } +} diff --git a/package-lock.json b/package-lock.json index 4622d60..91017ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "tsx": "^4.19.2" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "^22.10.2", "typescript": "^5.7.2" }, @@ -35,6 +36,169 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", + "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.15", + "@biomejs/cli-darwin-x64": "2.5.15", + "@biomejs/cli-linux-arm64": "2.5.15", + "@biomejs/cli-linux-arm64-musl": "2.5.15", + "@biomejs/cli-linux-x64": "2.5.15", + "@biomejs/cli-linux-x64-musl": "2.5.15", + "@biomejs/cli-win32-arm64": "2.5.15", + "@biomejs/cli-win32-x64": "2.5.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", + "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", + "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", + "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", + "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", + "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", + "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", + "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", + "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", diff --git a/package.json b/package.json index 57ca5a6..d922ba2 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,18 @@ "dev": "tsx watch src/index.ts", "typecheck": "tsc --noEmit", "smoke": "node --import tsx scripts/smoke.ts", - "regressions": "node --import tsx scripts/transaction-regressions.ts && node --import tsx scripts/copytrade-regressions.ts && node --import tsx scripts/wallet-regressions.ts && node --import tsx scripts/portfolio-regressions.ts && node --import tsx scripts/reconcile-regressions.ts && node --import tsx scripts/deep-transaction-regressions.ts && node --import tsx scripts/copyevents-regressions.ts && node --import tsx scripts/safety-regressions.ts && node --import tsx scripts/concurrency-regressions.ts && node --import tsx scripts/accounting-regressions.ts && node --import tsx scripts/reconcile-deep-regressions.ts && node --import tsx scripts/client-regressions.ts", + "regressions": "node --import tsx scripts/transaction-regressions.ts && node --import tsx scripts/copytrade-regressions.ts && node --import tsx scripts/wallet-regressions.ts && node --import tsx scripts/portfolio-regressions.ts && node --import tsx scripts/reconcile-regressions.ts && node --import tsx scripts/deep-transaction-regressions.ts && node --import tsx scripts/copyevents-regressions.ts && node --import tsx scripts/safety-regressions.ts && node --import tsx scripts/concurrency-regressions.ts && node --import tsx scripts/accounting-regressions.ts && node --import tsx scripts/reconcile-deep-regressions.ts && node --import tsx scripts/client-regressions.ts && node --import tsx scripts/trade-accounting-regressions.ts && node --import tsx scripts/behavior-regressions.ts && node --import tsx scripts/manual-trade-regressions.ts", "test": "npm run smoke && npm run regressions", "netcheck": "node --import tsx scripts/netcheck.ts", - "check": "npm run typecheck && npm test", + "check": "npm run quality && npm run typecheck && npm test", "check:live": "npm run check && npm run netcheck", - "batchsim": "node --import tsx scripts/batchsim.ts" + "batchsim": "node --import tsx scripts/batchsim.ts", + "format": "biome format --write .", + "lint": "biome lint --error-on-warnings .", + "lint:fix": "biome lint --write --error-on-warnings .", + "quality": "biome check --error-on-warnings .", + "quality:ci": "biome ci --error-on-warnings .", + "check:ci": "npm run quality:ci && npm run typecheck && npm test" }, "dependencies": { "@solana/spl-token": "^0.4.9", @@ -31,6 +37,7 @@ "tsx": "^4.19.2" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "^22.10.2", "typescript": "^5.7.2" }, diff --git a/scripts/accounting-regressions.ts b/scripts/accounting-regressions.ts index abd3cee..d0c0703 100644 --- a/scripts/accounting-regressions.ts +++ b/scripts/accounting-regressions.ts @@ -16,39 +16,80 @@ process.env.JUPITER_REQUEST_INTERVAL_MS = '0'; const { db } = await import('../src/store/db.js'); const { entryPrice, exitResult, positionPnl, accountPnl } = await import('../src/services/pnl.js'); -const { rpc, getSplBalances, getMintBalances, getMintDecimals, WSOL_MINT, sendSplToken } = await import('../src/chains/solana.js'); -const { measureTokensGained, measureTokensSold, isFreshEntry, batchSweepToken } = await import('../src/trade/engine.js'); -const { buildPortfolio, aggregateToken, listPositions } = await import('../src/services/portfolio.js'); -const { fire, runDueDca, entryPriceSol, ruleTriggered } = await import('../src/services/watcher.js'); +const { rpc, getSplBalances, getMintBalances, getMintDecimals, WSOL_MINT, sendSplToken } = + await import('../src/chains/solana.js'); +const { measureTokensGained, measureTokensSold, isFreshEntry, batchSweepToken } = await import( + '../src/trade/engine.js' +); +const { buildPortfolio, aggregateToken, listPositions } = await import( + '../src/services/portfolio.js' +); +const { fire, runDueDca, entryPriceSol, ruleTriggered } = await import( + '../src/services/watcher.js' +); const { initVaultWithKeyfile, lockVault } = await import('../src/store/vault.js'); const { generateSolanaWallet } = await import('../src/store/wallets.js'); const { clearPriceCache } = await import('../src/services/prices.js'); const { PublicKey, VersionedTransaction } = await import('@solana/web3.js'); -const { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, AccountLayout } = await import('@solana/spl-token'); +const { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, AccountLayout } = await import( + '@solana/spl-token' +); const bs58 = (await import('bs58')).default; const client = rpc(); const original = { - tokens: client.getParsedTokenAccountsByOwner, multiple: client.getMultipleAccountsInfo, - account: client.getAccountInfo, blockhash: client.getLatestBlockhash, - send: client.sendRawTransaction, statuses: client.getSignatureStatuses, fetch: globalThis.fetch, + tokens: client.getParsedTokenAccountsByOwner, + multiple: client.getMultipleAccountsInfo, + account: client.getAccountInfo, + blockhash: client.getLatestBlockhash, + send: client.sendRawTransaction, + statuses: client.getSignatureStatuses, + fetch: globalThis.fetch, }; let passed = 0; -const check = (name: string) => { passed++; console.log(` ✓ ${name}`); }; -const approx = (actual: number | null, expected: number) => assert.ok(actual !== null && Math.abs(actual - expected) < 1e-12, `${actual} != ${expected}`); +const check = (name: string) => { + passed++; + console.log(` ✓ ${name}`); +}; +const approx = (actual: number | null, expected: number) => + assert.ok(actual !== null && Math.abs(actual - expected) < 1e-12, `${actual} != ${expected}`); const mint = PublicKey.unique().toBase58(); -let decimals = 9; -let entries: Array<{ address: string; account: PublicKeyType; raw: bigint; ui: number | null; program: PublicKeyType; confidential?: boolean }> = []; +const decimals = 9; +let entries: Array<{ + address: string; + account: PublicKeyType; + raw: bigint; + ui: number | null; + program: PublicKeyType; + confidential?: boolean; +}> = []; let failToken22 = false; -function parsed(entry: typeof entries[number]) { +function parsed(entry: (typeof entries)[number]) { return { pubkey: entry.account, - account: { owner: entry.program, lamports: 2_039_280, executable: false, rentEpoch: 0, - data: { program: entry.program.equals(TOKEN_PROGRAM_ID) ? 'spl-token' : 'spl-token-2022', space: 165, - parsed: { info: { mint, owner: entry.address, - tokenAmount: { amount: entry.raw.toString(), decimals, uiAmount: entry.ui, uiAmountString: String(Number(entry.raw) / 10 ** decimals) }, - ...(entry.confidential ? { extensions: [{ extension: 'confidentialTransferAccount' }] } : {}), - } }, + account: { + owner: entry.program, + lamports: 2_039_280, + executable: false, + rentEpoch: 0, + data: { + program: entry.program.equals(TOKEN_PROGRAM_ID) ? 'spl-token' : 'spl-token-2022', + space: 165, + parsed: { + info: { + mint, + owner: entry.address, + tokenAmount: { + amount: entry.raw.toString(), + decimals, + uiAmount: entry.ui, + uiAmountString: String(Number(entry.raw) / 10 ** decimals), + }, + ...(entry.confidential + ? { extensions: [{ extension: 'confidentialTransferAccount' }] } + : {}), + }, + }, } as ParsedAccountData, }, }; @@ -61,8 +102,22 @@ try { db.recordBuy('partial', { solSpent: 1, fills: 1, tokensBought: 10 }); approx(entryPrice(db.position('partial')), 1.1 / 20); approx(exitResult(db.position('partial'), 20, 1.1)!.profitSol, 0); - assert.equal(ruleTriggered({ id: 'stop', mint: 'partial', kind: 'stop_loss', triggerPct: -50, - sellPercent: 100, enabled: true, createdAt: 1 }, 0.02, entryPrice(db.position('partial'))), true); + assert.equal( + ruleTriggered( + { + id: 'stop', + mint: 'partial', + kind: 'stop_loss', + triggerPct: -50, + sellPercent: 100, + enabled: true, + createdAt: 1, + }, + 0.02, + entryPrice(db.position('partial')), + ), + true, + ); check('partial sells retire basis before averaging in and preserve correct exits and stops'); db.recordBuy('unknown-buy', { solSpent: 1, fills: 1, tokensBought: 0 }); @@ -74,10 +129,20 @@ try { assert.equal(exitResult(db.position('unknown-sale'), 10, 1), null); db.recordBuy('unknown-sale', { solSpent: 2, fills: 1, tokensBought: 10, freshEntry: true }); approx(entryPrice(db.position('unknown-sale')), 0.2); - const legacy = { mint: 'legacy', investedSol: 1, tokensBought: 100, realisedSol: 0.5, - buyFills: 1, sellFills: 1, firstBuyAt: 1, lastTradeAt: 2 }; + const legacy = { + mint: 'legacy', + investedSol: 1, + tokensBought: 100, + realisedSol: 0.5, + buyFills: 1, + sellFills: 1, + firstBuyAt: 1, + lastTradeAt: 2, + }; assert.equal(entryPrice(legacy), null); - check('unknown quantities and legacy sales cannot become reliable basis through lifetime fallbacks'); + check( + 'unknown quantities and legacy sales cannot become reliable basis through lifetime fallbacks', + ); db.recordBuy('fees', { solSpent: 1, costSol: 1.02, fills: 1, tokensBought: 100 }); const feePos = db.position('fees')!; @@ -86,11 +151,28 @@ try { check('position and account profit both include measured fees and rent'); const ledgerBefore = structuredClone(db.positions()); - for (const patch of [{ solSpent: NaN }, { solSpent: Infinity }, { fills: Infinity }, { fills: 1.5 }, - { tokensBought: NaN }, { tokensBought: -1 }, { costSol: Infinity }, { costSol: -1 }, { decimals: 256 }, { decimals: 1.5 }]) { + for (const patch of [ + { solSpent: NaN }, + { solSpent: Infinity }, + { fills: Infinity }, + { fills: 1.5 }, + { tokensBought: NaN }, + { tokensBought: -1 }, + { costSol: Infinity }, + { costSol: -1 }, + { decimals: 256 }, + { decimals: 1.5 }, + ]) { db.recordBuy('fees', { solSpent: 1, fills: 1, tokensBought: 100, ...patch }); } - for (const [sol, fills, quantity] of [[NaN, 1, 1], [Infinity, 1, 1], [1, Infinity, 1], [1, 1.5, 1], [1, 1, NaN], [1, 1, -1]]) { + for (const [sol, fills, quantity] of [ + [NaN, 1, 1], + [Infinity, 1, 1], + [1, Infinity, 1], + [1, 1.5, 1], + [1, 1, NaN], + [1, 1, -1], + ]) { db.recordSell('fees', sol!, fills!, quantity); } assert.deepEqual(db.positions(), ledgerBefore); @@ -103,83 +185,200 @@ try { const wallet = generateSolanaWallet('accounting-wallet'); const other: WalletRecord = { ...wallet, id: 'other', address: PublicKey.unique().toBase58() }; client.getParsedTokenAccountsByOwner = async (owner, filter) => { - if ('programId' in filter && filter.programId.equals(TOKEN_2022_PROGRAM_ID) && failToken22) throw new Error('offline Token-2022 outage'); - return { context: { slot: 1 }, value: entries.filter((e) => e.address === owner.toBase58() && - ('mint' in filter || e.program.equals(filter.programId))).map(parsed) }; + if ('programId' in filter && filter.programId.equals(TOKEN_2022_PROGRAM_ID) && failToken22) + throw new Error('offline Token-2022 outage'); + return { + context: { slot: 1 }, + value: entries + .filter( + e => + e.address === owner.toBase58() && + ('mint' in filter || e.program.equals(filter.programId)), + ) + .map(parsed), + }; }; - client.getMultipleAccountsInfo = async (keys) => keys.map(() => ({ owner: PublicKey.default, - data: Buffer.alloc(0), lamports: 1e9, executable: false, rentEpoch: 0 })); + client.getMultipleAccountsInfo = async keys => + keys.map(() => ({ + owner: PublicKey.default, + data: Buffer.alloc(0), + lamports: 1e9, + executable: false, + rentEpoch: 0, + })); client.getAccountInfo = async () => { - const data = Buffer.alloc(82); data[44] = decimals; data[45] = 1; + const data = Buffer.alloc(82); + data[44] = decimals; + data[45] = 1; return { owner: TOKEN_PROGRAM_ID, data, lamports: 1, executable: false, rentEpoch: 0 }; }; - globalThis.fetch = async () => new Response(JSON.stringify({ [WSOL_MINT]: { usdPrice: 100 }, [mint]: { usdPrice: 5 } }), { status: 200 }); + globalThis.fetch = async () => + new Response(JSON.stringify({ [WSOL_MINT]: { usdPrice: 100 }, [mint]: { usdPrice: 5 } }), { + status: 200, + }); entries = [ - { address: wallet.address, account: PublicKey.unique(), raw: 1_000_000_000n, ui: null, program: TOKEN_PROGRAM_ID }, - { address: wallet.address, account: PublicKey.unique(), raw: 2_000_000_000n, ui: 200, program: TOKEN_PROGRAM_ID }, + { + address: wallet.address, + account: PublicKey.unique(), + raw: 1_000_000_000n, + ui: null, + program: TOKEN_PROGRAM_ID, + }, + { + address: wallet.address, + account: PublicKey.unique(), + raw: 2_000_000_000n, + ui: 200, + program: TOKEN_PROGRAM_ID, + }, ]; assert.equal(await getMintDecimals(mint), 9); const holdings = await getSplBalances(wallet.address); - assert.deepEqual(holdings.map((h) => h.amount), [1, 2]); + assert.deepEqual( + holdings.map(h => h.amount), + [1, 2], + ); assert.equal((await getMintBalances([wallet.address], mint)).get(wallet.address), 3_000_000_000n); const portfolio = await buildPortfolio(); assert.equal(aggregateToken(portfolio, mint).totalAmount, 3); assert.equal(aggregateToken(portfolio, mint).totalUsd, 15); assert.equal(listPositions(portfolio)[0]!.walletCount, 1); - check('all mint accounts are summed once per wallet and nullable or scaled UI floats cannot alter accounting units'); + check( + 'all mint accounts are summed once per wallet and nullable or scaled UI floats cannot alter accounting units', + ); - const before = new Map([[wallet.address, 1_000_000_000n], [other.address, 100_000_000_000n]]); + const before = new Map([ + [wallet.address, 1_000_000_000n], + [other.address, 100_000_000_000n], + ]); assert.equal(await measureTokensGained([wallet.address], mint, before, 9), 2); entries = [{ ...entries[0]!, raw: 500_000_000n }]; assert.equal(await measureTokensSold([wallet.address], mint, before, 9), 0.5); assert.equal(await measureTokensGained([wallet.address], mint, new Map(), undefined), 0); assert.equal(await measureTokensSold([wallet.address], mint, before, undefined), 0); assert.equal(isFreshEntry(new Map([[other.address, 1n]])), false); - check('measurements use actual decimals and only the selected wallet delta even with account-wide before balances'); + check( + 'measurements use actual decimals and only the selected wallet delta even with account-wide before balances', + ); db.recordBuy(mint, { solSpent: 1, fills: 1, tokensBought: 100, freshEntry: true, decimals: 9 }); const services: WatcherTradeServices = { - selectWallets: () => [wallet], allWallets: () => [wallet, other], - getMintBalances: async (addresses) => { assert.ok(addresses.includes(other.address)); return new Map([[other.address, 100_000_000_000n]]); }, + selectWallets: () => [wallet], + allWallets: () => [wallet, other], + getMintBalances: async addresses => { + assert.ok(addresses.includes(other.address)); + return new Map([[other.address, 100_000_000_000n]]); + }, getMintDecimals: async () => 9, - measureTokensGained: async (_addresses, _mint, _before, actualDecimals) => { assert.equal(actualDecimals, 9); return 1; }, + measureTokensGained: async (_addresses, _mint, _before, actualDecimals) => { + assert.equal(actualDecimals, 9); + return 1; + }, measureTokensSold: async () => 1, - batchPumpTrade: async () => ({ results: [{ walletId: wallet.id, address: wallet.address, label: wallet.label, - ok: true, signature: 'offline-fill' }], succeeded: 1, failed: 0, startedAt: 1, finishedAt: 2, solSpent: 1 }), + batchPumpTrade: async () => ({ + results: [ + { + walletId: wallet.id, + address: wallet.address, + label: wallet.label, + ok: true, + signature: 'offline-fill', + }, + ], + succeeded: 1, + failed: 0, + startedAt: 1, + finishedAt: 2, + solSpent: 1, + }), }; - const rule = { id: 'nine-decimal-limit', mint, kind: 'limit_buy' as const, triggerPct: 0, - sellPercent: 100, buySol: 1, triggerPriceSol: 1, enabled: true, createdAt: 1 }; - db.addRule(rule); await fire(rule, 1, async () => {}, services); + const rule = { + id: 'nine-decimal-limit', + mint, + kind: 'limit_buy' as const, + triggerPct: 0, + sellPercent: 100, + buySol: 1, + triggerPriceSol: 1, + enabled: true, + createdAt: 1, + }; + db.addRule(rule); + await fire(rule, 1, async () => {}, services); approx(entryPriceSol(mint), 2 / 101); - db.addDcaPlan({ id: 'nine-decimal-dca', mint, buySol: 1, roundsDone: 0, roundsTotal: 1, - intervalMinutes: 1, nextRunAt: 0, enabled: true, createdAt: 1 }); + db.addDcaPlan({ + id: 'nine-decimal-dca', + mint, + buySol: 1, + roundsDone: 0, + roundsTotal: 1, + intervalMinutes: 1, + nextRunAt: 0, + enabled: true, + createdAt: 1, + }); await runDueDca(async () => {}, services); approx(entryPriceSol(mint), 3 / 102); assert.equal(db.position(mint)!.decimals, 9); check('limit and DCA buys read actual decimals and preserve another wallet group’s open basis'); const uncertainMint = PublicKey.unique().toBase58(); - const uncertainRule = { ...rule, id: 'mixed-confirmation-limit', mint: uncertainMint, firedAt: undefined }; + const uncertainRule = { + ...rule, + id: 'mixed-confirmation-limit', + mint: uncertainMint, + firedAt: undefined, + }; db.addRule(uncertainRule); - const uncertainServices: WatcherTradeServices = { ...services, + const uncertainServices: WatcherTradeServices = { + ...services, getMintBalances: async () => new Map(), measureTokensGained: async (addresses, _mint, _before, actualDecimals) => { - assert.deepEqual(addresses, [wallet.address]); assert.equal(actualDecimals, 9); return 1; + assert.deepEqual(addresses, [wallet.address]); + assert.equal(actualDecimals, 9); + return 1; }, - batchPumpTrade: async () => ({ results: [ - { walletId: wallet.id, address: wallet.address, label: wallet.label, ok: true, signature: 'confirmed' }, - { walletId: other.id, address: other.address, label: other.label, ok: false, signature: 'pending', confirmationUnknown: true }, - ], succeeded: 1, failed: 1, startedAt: 1, finishedAt: 2 }), + batchPumpTrade: async () => ({ + results: [ + { + walletId: wallet.id, + address: wallet.address, + label: wallet.label, + ok: true, + signature: 'confirmed', + }, + { + walletId: other.id, + address: other.address, + label: other.label, + ok: false, + signature: 'pending', + confirmationUnknown: true, + }, + ], + succeeded: 1, + failed: 1, + startedAt: 1, + finishedAt: 2, + }), }; let note = ''; - await fire(uncertainRule, 1, async (text) => { note = text; }, uncertainServices); + await fire( + uncertainRule, + 1, + async text => { + note = text; + }, + uncertainServices, + ); assert.equal(db.position(uncertainMint)!.tokensBought, 1); assert.equal(db.position(uncertainMint)!.investedSol, 1); assert.equal(entryPriceSol(uncertainMint), null); assert.match(note, /Entry basis and proceeds are unknown/); check('mixed confirmations count only confirmed token deltas and keep the open basis unknown'); - failToken22 = true; clearPriceCache(); + failToken22 = true; + clearPriceCache(); const partial = await buildPortfolio(); assert.match(partial.errors.join(' '), /Token-2022 outage/); failToken22 = false; @@ -192,45 +391,104 @@ try { entries = [{ ...entries[0]!, raw: 0n, confidential: true, program: TOKEN_2022_PROGRAM_ID }]; await assert.rejects(getSplBalances(wallet.address), /Confidential/); await assert.rejects(getMintBalances([wallet.address], mint), /Confidential/); - check('Token-2022 outages and encrypted balances remain unknown instead of becoming a complete zero valuation'); + check( + 'Token-2022 outages and encrypted balances remain unknown instead of becoming a complete zero valuation', + ); entries = [ - { address: wallet.address, account: PublicKey.unique(), raw: 1_000_000_000n, ui: 1, program: TOKEN_PROGRAM_ID }, - { address: wallet.address, account: PublicKey.unique(), raw: 2_000_000_000n, ui: 2, program: TOKEN_PROGRAM_ID }, + { + address: wallet.address, + account: PublicKey.unique(), + raw: 1_000_000_000n, + ui: 1, + program: TOKEN_PROGRAM_ID, + }, + { + address: wallet.address, + account: PublicKey.unique(), + raw: 2_000_000_000n, + ui: 2, + program: TOKEN_PROGRAM_ID, + }, ]; - client.getLatestBlockhash = async () => ({ blockhash: PublicKey.default.toBase58(), lastValidBlockHeight: 1 }); - client.getSignatureStatuses = async () => ({ context: { slot: 1 }, value: [{ slot: 1, - confirmations: 1, err: null, confirmationStatus: 'confirmed' }] }); + client.getLatestBlockhash = async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }); + client.getSignatureStatuses = async () => ({ + context: { slot: 1 }, + value: [{ slot: 1, confirmations: 1, err: null, confirmationStatus: 'confirmed' }], + }); const sweptSources: string[] = []; - client.sendRawTransaction = async (raw) => { + client.sendRawTransaction = async raw => { const tx = VersionedTransaction.deserialize(Uint8Array.from(raw)); const keys = tx.message.staticAccountKeys; for (const ix of tx.message.compiledInstructions) { - if (keys[ix.programIdIndex]!.equals(TOKEN_PROGRAM_ID) && ix.data[0] === 9) sweptSources.push(keys[ix.accountKeyIndexes[0]!]!.toBase58()); + if (keys[ix.programIdIndex]!.equals(TOKEN_PROGRAM_ID) && ix.data[0] === 9) + sweptSources.push(keys[ix.accountKeyIndexes[0]!]!.toBase58()); } return bs58.encode(tx.signatures[0]!); }; const swept = await batchSweepToken([wallet], mint, PublicKey.unique().toBase58()); assert.equal(swept.succeeded, 2); - assert.deepEqual(sweptSources, entries.map((e) => e.account.toBase58())); + assert.deepEqual( + sweptSources, + entries.map(e => e.account.toBase58()), + ); check('one token sweep moves and closes every matching account including non-ATAs'); const source = PublicKey.unique(); const extended = Buffer.alloc(178); - AccountLayout.encode({ mint: new PublicKey(mint), owner: new PublicKey(wallet.address), amount: 1_000_000_000n, - delegateOption: 0, delegate: PublicKey.default, state: 1, isNativeOption: 0, isNative: 0n, - delegatedAmount: 0n, closeAuthorityOption: 0, closeAuthority: PublicKey.default }, extended); - extended[165] = 2; extended.writeUInt16LE(2, 166); extended.writeUInt16LE(8, 168); extended.writeBigUInt64LE(10n, 170); - client.getAccountInfo = async () => ({ owner: TOKEN_2022_PROGRAM_ID, data: extended, lamports: 1, executable: false, rentEpoch: 0 }); + AccountLayout.encode( + { + mint: new PublicKey(mint), + owner: new PublicKey(wallet.address), + amount: 1_000_000_000n, + delegateOption: 0, + delegate: PublicKey.default, + state: 1, + isNativeOption: 0, + isNative: 0n, + delegatedAmount: 0n, + closeAuthorityOption: 0, + closeAuthority: PublicKey.default, + }, + extended, + ); + extended[165] = 2; + extended.writeUInt16LE(2, 166); + extended.writeUInt16LE(8, 168); + extended.writeBigUInt64LE(10n, 170); + client.getAccountInfo = async () => ({ + owner: TOKEN_2022_PROGRAM_ID, + data: extended, + lamports: 1, + executable: false, + rentEpoch: 0, + }); let harvested = false; - client.sendRawTransaction = async (raw) => { + client.sendRawTransaction = async raw => { const tx = VersionedTransaction.deserialize(Uint8Array.from(raw)); - harvested = tx.message.compiledInstructions.some((ix) => tx.message.staticAccountKeys[ix.programIdIndex]!.equals(TOKEN_2022_PROGRAM_ID) && ix.data[0] === 26 && ix.data[1] === 4); + harvested = tx.message.compiledInstructions.some( + ix => + tx.message.staticAccountKeys[ix.programIdIndex]!.equals(TOKEN_2022_PROGRAM_ID) && + ix.data[0] === 26 && + ix.data[1] === 4, + ); return bs58.encode(tx.signatures[0]!); }; const { solanaKeypair } = await import('../src/store/wallets.js'); - await sendSplToken(solanaKeypair(wallet), PublicKey.unique().toBase58(), mint, 1_000_000_000n, - decimals, 0, TOKEN_2022_PROGRAM_ID.toBase58(), true, source.toBase58()); + await sendSplToken( + solanaKeypair(wallet), + PublicKey.unique().toBase58(), + mint, + 1_000_000_000n, + decimals, + 0, + TOKEN_2022_PROGRAM_ID.toBase58(), + true, + source.toBase58(), + ); assert.equal(harvested, true); check('withheld transfer fees are harvested before closing a Token-2022 source account'); console.log(`\n${passed} offline accounting regressions passed.`); @@ -242,5 +500,6 @@ try { client.sendRawTransaction = original.send; client.getSignatureStatuses = original.statuses; globalThis.fetch = original.fetch; - lockVault(); fs.rmSync(dataDir, { recursive: true, force: true }); + lockVault(); + fs.rmSync(dataDir, { recursive: true, force: true }); } diff --git a/scripts/batchsim.ts b/scripts/batchsim.ts index 027df06..84566ec 100644 --- a/scripts/batchsim.ts +++ b/scripts/batchsim.ts @@ -24,7 +24,7 @@ process.env.DATA_DIR = './.batchsim-data'; process.env.VAULT_AUTOLOCK_MINUTES = '0'; import fs from 'node:fs'; -import { Keypair, VersionedTransaction } from '@solana/web3.js'; +import { Keypair, type VersionedTransaction } from '@solana/web3.js'; const DATA = './.batchsim-data'; fs.rmSync(DATA, { recursive: true, force: true }); @@ -49,22 +49,30 @@ async function liveMint(): Promise { if (requestedMint) return requestedMint; const res = await fetch('https://api.dexscreener.com/token-profiles/latest/v1'); const profiles = (await res.json()) as Array<{ chainId: string; tokenAddress: string }>; - const sol = profiles.filter((p) => p.chainId === 'solana'); - return (sol.find((p) => p.tokenAddress.endsWith('pump')) ?? sol[0]!).tokenAddress; + const sol = profiles.filter(p => p.chainId === 'solana'); + return (sol.find(p => p.tokenAddress.endsWith('pump')) ?? sol[0]!).tokenAddress; } const mint = await liveMint(); const settings = db.settings(); console.log(`\n wallets ${walletCount}`); -console.log(` size ${solPerWallet} SOL each (${(solPerWallet * walletCount).toFixed(3)} SOL total)`); +console.log( + ` size ${solPerWallet} SOL each (${(solPerWallet * walletCount).toFixed(3)} SOL total)`, +); console.log(` token ${mint}`); const pool = await detectPool(mint); console.log(` venue ${pool}`); -console.log(` needs ${(Number(requiredForBuy(solPerWallet, settings.priorityFeeSol, { - wrapsSol: pool !== 'pump', -})) / 1e9).toFixed(5)} SOL per wallet\n`); +console.log( + ` needs ${( + Number( + requiredForBuy(solPerWallet, settings.priorityFeeSol, { + wrapsSol: pool !== 'pump', + }), + ) / 1e9 + ).toFixed(5)} SOL per wallet\n`, +); // Throwaway wallets. They hold nothing, which is the point: an unfunded wallet // must fail with a fundable-looking error rather than a malformed transaction, @@ -97,7 +105,9 @@ async function findRecentTrader(): Promise { const sigs = await rpc().getSignaturesForAddress(new PublicKey(mint), { limit: 12 }); for (const s of sigs) { if (s.err) continue; - const [tx] = await rpc().getParsedTransactions([s.signature], { maxSupportedTransactionVersion: 0 }); + const [tx] = await rpc().getParsedTransactions([s.signature], { + maxSupportedTransactionVersion: 0, + }); // the fee payer is the first account and is always a plain wallet const payer = tx?.transaction.message.accountKeys?.[0]?.pubkey?.toBase58(); if (!payer) continue; @@ -158,7 +168,8 @@ const started = Date.now(); /** How the chain answered. Grouped, because fifty wallets fail the same way. */ function classify(err: unknown): string { const text = typeof err === 'string' ? err : JSON.stringify(err); - if (/insufficient lamports|debit an account/i.test(text)) return 'insufficient funds (expected — wallet is empty)'; + if (/insufficient lamports|debit an account/i.test(text)) + return 'insufficient funds (expected — wallet is empty)'; if (/slippage|0x1771|TooMuchSolRequired/i.test(text)) return 'slippage exceeded'; if (/BlockhashNotFound/i.test(text)) return 'blockhash expired'; if (/AccountNotFound|could not find account/i.test(text)) return 'account missing'; @@ -192,7 +203,7 @@ await Promise.all( row.bytes = tx.serialize().length; const signed = signTx(tx, kp); - row.signed = signed.signatures.some((s) => s.some((b) => b !== 0)); + row.signed = signed.signatures.some(s => s.some(b => b !== 0)); const sim = await rpc().simulateTransaction(signed as VersionedTransaction, { replaceRecentBlockhash: true, @@ -212,17 +223,18 @@ await Promise.all( // ── report ──────────────────────────────────────────────────────────────────── const elapsed = (Date.now() - started) / 1000; -const built = rows.filter((r) => r.built); -const signedOk = rows.filter((r) => r.signed); +const built = rows.filter(r => r.built); +const signedOk = rows.filter(r => r.signed); console.log(` ${'─'.repeat(58)}`); console.log(` built ${built.length}/${rows.length}`); console.log(` signed ${signedOk.length}/${rows.length}`); -const sizes = [...new Set(built.map((r) => r.bytes))]; +const sizes = [...new Set(built.map(r => r.bytes))]; console.log(` tx size ${sizes.join(', ')} bytes (limit 1232)`); -const oversize = built.filter((r) => (r.bytes ?? 0) > 1232); -if (oversize.length > 0) console.log(` ⚠️ ${oversize.length} transaction(s) exceed the packet limit`); +const oversize = built.filter(r => (r.bytes ?? 0) > 1232); +if (oversize.length > 0) + console.log(` ⚠️ ${oversize.length} transaction(s) exceed the packet limit`); const outcomes = new Map(); for (const r of rows) { @@ -235,15 +247,19 @@ for (const [outcome, n] of [...outcomes].sort((a, b) => b[1] - a[1])) { console.log(` ${String(n).padStart(3)}× ${outcome}`); } -const times = rows.map((r) => r.ms).sort((a, b) => a - b); -console.log(`\n per wallet median ${times[Math.floor(times.length / 2)]}ms slowest ${times.at(-1)}ms`); -console.log(` wall clock ${elapsed.toFixed(1)}s for ${rows.length} wallets at concurrency ${concurrency}`); +const times = rows.map(r => r.ms).sort((a, b) => a - b); +console.log( + `\n per wallet median ${times[Math.floor(times.length / 2)]}ms slowest ${times.at(-1)}ms`, +); +console.log( + ` wall clock ${elapsed.toFixed(1)}s for ${rows.length} wallets at concurrency ${concurrency}`, +); // the question the empty wallets cannot answer console.log(`\n against a funded account:`); console.log(` ${await probeFunded()}`); -const fatal = rows.filter((r) => r.error).length; +const fatal = rows.filter(r => r.error).length; console.log( `\n ${fatal === 0 ? '✅ every wallet produced a signed transaction the chain accepted as well-formed' : `❌ ${fatal} wallet(s) could not build at all`}\n`, ); diff --git a/scripts/behavior-regressions.ts b/scripts/behavior-regressions.ts new file mode 100644 index 0000000..b11bf97 --- /dev/null +++ b/scripts/behavior-regressions.ts @@ -0,0 +1,31 @@ +/** Offline integration checks grouped by behavior; no source-code matching. */ +import fs from 'node:fs'; +import { runCopyBehaviors } from './behaviors/copy.js'; +import { runWatcherBehaviors } from './behaviors/watcher.js'; +import { runStorageBehaviors } from './behaviors/storage.js'; +import { runTokenBehaviors } from './behaviors/token.js'; +import { runUiBehaviors } from './behaviors/ui.js'; +import { + dataDir, + vault, + passCount, + originalFetch, + originalSetTimeout, + originalNow, +} from './behaviors/fixtures.js'; + +try { + vault.initVaultWithKeyfile(); + await runCopyBehaviors(); + await runWatcherBehaviors(); + await runStorageBehaviors(); + await runTokenBehaviors(); + await runUiBehaviors(); + console.log(`\n${passCount()} public-behavior regressions passed.`); +} finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + Date.now = originalNow; + vault.lockVault(); + fs.rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/scripts/behaviors/copy.ts b/scripts/behaviors/copy.ts new file mode 100644 index 0000000..4d82c09 --- /dev/null +++ b/scripts/behaviors/copy.ts @@ -0,0 +1,273 @@ +/** Offline public-behavior regressions. No source-code matching or live services. */ +import assert from 'node:assert/strict'; +import { Keypair } from '@solana/web3.js'; +import type { CopySellServices } from '../../src/services/copytrade.js'; + +import { + db, + mirrorBuy, + mirrorSell, + reviewFeedHealth, + queueEvictionIndex, + check, + deferred, + settle, + wallet, + filled, + target, + buyServices, +} from './fixtures.js'; + +export async function runCopyBehaviors(): Promise { + { + assert.equal( + reviewFeedHealth({ socket: 1, poll: 10 }, false).rebuild, + false, + 'a tiny sample cannot establish a broken socket', + ); + const broken = reviewFeedHealth({ socket: 4, poll: 8 }, false); + assert.deepEqual(broken, { rebuild: true, warned: true, claims: { socket: 0, poll: 0 } }); + assert.equal( + reviewFeedHealth({ socket: 4, poll: 8 }, true).rebuild, + false, + 'an outstanding warning does not cause a rebuild loop', + ); + assert.equal( + reviewFeedHealth({ socket: 9, poll: 3 }, true).warned, + false, + 'healthy delivery clears the warning', + ); + assert.equal( + reviewFeedHealth({ socket: 4, poll: 8 }, false).rebuild, + true, + 'a later failure can be detected again', + ); + assert.deepEqual(reviewFeedHealth({ socket: 180, poll: 30 }, false).claims, { + socket: 90, + poll: 15, + }); + assert.equal(queueEvictionIndex([]), -1); + assert.equal(queueEvictionIndex(['quiet', 'busy', 'busy', 'quiet', 'busy']), 1); + assert.equal( + queueEvictionIndex(['first', 'second', 'second', 'first']), + 0, + 'ties preserve arrival order', + ); + check( + 'feed health rebuilds once, recovers and bounds history while queue eviction protects quieter targets', + ); + } + + // The screen and balances must both start before either finishes. This fails + // if the production code accidentally serializes the two network reads. + { + db.wipe(); + const value = target(); + const screen = deferred<{ verdict: { safe: boolean; reasons: string[]; notes: string[] } }>(); + const balances = deferred>(); + const started: string[] = []; + let trades = 0; + const services = buyServices(); + services.screenToken = () => { + started.push('screen'); + return screen.promise; + }; + services.getMintBalances = () => { + started.push('balances'); + return balances.promise; + }; + services.batchPumpTrade = async () => { + trades++; + return filled(); + }; + const work = mirrorBuy( + value, + { mint: 'overlap', delta: 100, before: 0 }, + 1, + async () => {}, + services, + ); + await settle(); + assert.deepEqual(started, ['screen', 'balances']); + assert.equal(trades, 0); + balances.resolve(new Map([[wallet.address, 0n]])); + await settle(); + assert.equal(trades, 0); + screen.resolve({ verdict: { safe: true, reasons: [], notes: [] } }); + await work; + assert.equal(trades, 1); + assert.equal(db.position('overlap')?.buyFills, 1); + check('copy screening overlaps balance reads and execution waits for both answers'); + } + { + db.wipe(); + const value = target(); + const screen = deferred(); + const services = buyServices(); + let trades = 0; + const notices: string[] = []; + services.screenToken = () => screen.promise; + services.getMintBalances = async () => new Map([[wallet.address, 1n]]); + services.batchPumpTrade = async () => { + trades++; + return filled(); + }; + await mirrorBuy( + value, + { mint: 'already-held', delta: 100, before: 0 }, + 1, + async text => { + notices.push(text); + }, + services, + ); + screen.reject(new Error('late screening failure')); + await settle(); + assert.equal(trades, 0); + assert.equal(notices.length, 0); + assert.match(db.copyDecisions()[0]!.reason, /already hold/); + check('an early holding refusal records quietly and consumes a later screening rejection'); + } + { + db.wipe(); + const notices: string[] = []; + let trades = 0; + const services = buyServices(); + services.batchPumpTrade = async () => { + trades++; + return filled(); + }; + const notify = async (text: string) => { + notices.push(text); + }; + const refused = target({ refusedMints: ['refused'] }); + await mirrorBuy(refused, { mint: 'refused', delta: 100, before: 0 }, 1, notify, services); + assert.match(db.copyDecisions()[0]!.reason, /Already refused/); + const capped = target({ entryMode: 'every', maxEntries: 2, entryCounts: { capped: 2 } }); + await mirrorBuy(capped, { mint: 'capped', delta: 100, before: 0 }, 1, notify, services); + assert.match(db.copyDecisions()[0]!.reason, /Already taken 2/); + const tiny = target({ buySol: 0 }); + await mirrorBuy(tiny, { mint: 'tiny', delta: 100, before: 0 }, 1, notify, services); + assert.match(db.copyDecisions()[0]!.reason, /too small/); + services.screenToken = async () => ({ + verdict: { safe: false, reasons: ['fixture unsafe'], notes: [] }, + }); + const unsafe = target(); + await mirrorBuy(unsafe, { mint: 'unsafe', delta: 100, before: 0 }, 1, notify, services); + assert.match(db.copyDecisions()[0]!.reason, /fixture unsafe/); + assert.ok(unsafe.refusedMints?.includes('unsafe')); + assert.equal(trades, 0); + assert.equal(notices.length, 0); + check( + 'refused tokens, entry caps, zero sizes and safety refusals record reasons without trading or alerts', + ); + } + { + db.wipe(); + db.updateSettings({ copySafety: { ...db.settings().copySafety, maxSolPerMint: 0.02 } }); + const notices: string[] = []; + const notify = async (text: string) => { + notices.push(text); + }; + const services = buyServices(); + const fixed = target(); + await mirrorBuy(fixed, { mint: 'cap-a', delta: 100, before: 0 }, 1, notify, services); + await mirrorBuy(fixed, { mint: 'cap-b', delta: 100, before: 0 }, 1, notify, services); + assert.equal(notices.length, 1); + db.updateSettings({ copySafety: { ...db.settings().copySafety, maxSolPerMint: 0.01 } }); + await mirrorBuy(fixed, { mint: 'cap-c', delta: 100, before: 0 }, 1, notify, services); + assert.equal(notices.length, 2, 'changed configuration can warn again'); + const percent = target({ sizeMode: 'percent', sizePercent: 5 }); + await mirrorBuy(percent, { mint: 'cap-percent', delta: 100, before: 0 }, 1, notify, services); + assert.equal(notices.length, 2, 'a large followed trade is not a fixed-size misconfiguration'); + assert.equal(db.positions().length, 0); + check('fixed copy sizing warns once per configuration while percent sizing stays quiet'); + } + { + db.wipe(); + const value = target({ copiedMints: ['copied-exit'] }); + db.recordBuy('copied-exit', { + solSpent: 0.05, + fills: 1, + tokensBought: 100, + costSol: 0.05, + freshEntry: true, + decimals: 6, + }); + const selection: unknown[] = []; + const notices: string[] = []; + let trades = 0; + let held = false; + const notify = async (text: string) => { + notices.push(text); + }; + const services: CopySellServices = { + selectWallets: options => { + selection.push(options); + return [wallet]; + }, + getMintBalances: async () => (held ? new Map([[wallet.address, 100_000_000n]]) : new Map()), + getMintDecimals: async () => 6, + batchPumpTrade: async () => { + trades++; + return filled(); + }, + measureTokensSold: async () => 100, + }; + await mirrorSell( + value, + { mint: 'someone-elses-position', delta: -100, before: 100 }, + notify, + services, + ); + assert.match(db.copyDecisions()[0]!.reason, /did not copy/); + assert.equal(selection.length, 0); + await mirrorSell(value, { mint: 'copied-exit', delta: -100, before: 100 }, notify, services); + assert.match(db.copyDecisions()[0]!.reason, /hold none/); + assert.equal(trades, 0); + assert.equal(notices.length, 0); + held = true; + await mirrorSell(value, { mint: 'copied-exit', delta: -100, before: 100 }, notify, services); + assert.deepEqual(selection, [{ group: null }, { group: null }]); + assert.equal(trades, 1); + assert.equal(db.position('copied-exit')?.realisedSol, 0.1); + assert.ok(notices.some(text => text.includes('Profit') && text.includes('+0.0500'))); + assert.equal(db.position('copied-exit')?.basisTokens, 0); + db.recordBuy('copied-exit', { + solSpent: 0.05, + fills: 1, + tokensBought: 100, + costSol: 0.05, + freshEntry: true, + decimals: 6, + }); + services.batchPumpTrade = async () => ({ + ...filled(), + results: [ + ...filled().results, + { + walletId: 'unknown-wallet', + address: Keypair.generate().publicKey.toBase58(), + label: 'Unknown', + ok: false, + signature: 'pending', + confirmationUnknown: true, + }, + ], + failed: 1, + solReceived: undefined, + }); + await mirrorSell(value, { mint: 'copied-exit', delta: -100, before: 100 }, notify, services); + assert.equal(db.position('copied-exit')?.sellFills, 2); + assert.equal( + db.position('copied-exit')?.realisedSol, + 0.1, + 'uncertain proceeds cannot manufacture a return', + ); + assert.equal(db.position('copied-exit')?.basisKnown, false); + assert.ok(notices.some(text => text.includes('may still land'))); + check( + 'copied exits use every group, report pre-sale profit and preserve unknown accounting on uncertain fills', + ); + } +} diff --git a/scripts/behaviors/fixtures.ts b/scripts/behaviors/fixtures.ts new file mode 100644 index 0000000..2101e3c --- /dev/null +++ b/scripts/behaviors/fixtures.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + Keypair, + type PublicKey, + type Commitment, + type GetProgramAccountsConfig, + type GetProgramAccountsResponse, + type RpcResponseAndContext, +} from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import type { Context, InlineKeyboard } from 'grammy'; +import type { CopyTarget } from '../../src/store/db.js'; +import type { BatchSummary, WalletRecord } from '../../src/types.js'; +import type { CopyBuyServices } from '../../src/services/copytrade.js'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solfleet-behaviors-')); +Object.assign(process.env, { + BOT_TOKEN: '123:OFFLINE_TEST', + OWNER_IDS: '1', + DATA_DIR: dataDir, + VAULT_AUTOLOCK_MINUTES: '0', + JUPITER_REQUEST_INTERVAL_MS: '0', + SOLANA_RPC_URL: 'http://127.0.0.1:8899', + SOLANA_SEND_RPC_URL: 'http://127.0.0.1:8899', +}); + +const { db } = await import('../../src/store/db.js'); +const vault = await import('../../src/store/vault.js'); +const wallets = await import('../../src/store/wallets.js'); +const { rpc, BASE_FEE_LAMPORTS } = await import('../../src/chains/solana.js'); +const { fire } = await import('../../src/services/watcher.js'); +const { mirrorBuy, mirrorSell, armCopyRules } = await import('../../src/services/copytrade.js'); +const { getTokenInfo } = await import('../../src/services/tokeninfo.js'); +const { readTokenLocks } = await import('../../src/services/locks.js'); +const { createNotifier } = await import('../../src/services/notifications.js'); +const { createBot } = await import('../../src/bot/index.js'); +const session = await import('../../src/bot/session.js'); +const handlers = await import('../../src/bot/handlers/trade.js'); +const ui = await import('../../src/bot/ui.js'); +const { rebuildPnl } = await import('../../src/bot/handlers/core.js'); +const { batchSweepSol } = await import('../../src/trade/engine.js'); +const { exitReserveLamports } = await import('../../src/trade/fund.js'); +const { reviewFeedHealth, queueEvictionIndex } = await import( + '../../src/services/copytrade/intake-policy.js' +); +const client = rpc(); +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; +const originalNow = Date.now; +let passed = 0; +const check = (name: string) => { + passed++; + console.log(` ✓ ${name}`); +}; +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; +const settle = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; +const wallet: WalletRecord = { + id: 'offline-wallet', + kind: 'solana', + address: Keypair.generate().publicKey.toBase58(), + label: 'Offline', + secret: '', + groups: [], + isMain: false, + disabled: false, + createdAt: 1, +}; +const filled = (): BatchSummary => ({ + results: [ + { + walletId: wallet.id, + address: wallet.address, + label: wallet.label, + ok: true, + signature: 'confirmed', + }, + ], + succeeded: 1, + failed: 0, + startedAt: 1, + finishedAt: 2, + solSpent: 0.05, + solReceived: 0.1, +}); +const rejected = (): BatchSummary => ({ + results: [ + { + walletId: wallet.id, + address: wallet.address, + label: wallet.label, + ok: false, + error: 'definitely rejected', + }, + ], + succeeded: 0, + failed: 1, + startedAt: 1, + finishedAt: 2, +}); +let sequence = 0; +function target(patch: Partial = {}): CopyTarget { + const value: CopyTarget = { + id: `target-${++sequence}`, + address: `trader-${sequence}`, + label: 'Offline trader', + buySol: 0.05, + sizeMode: 'fixed', + sizePercent: 5, + entryMode: 'first', + maxEntries: 1, + exitMode: 'all', + copiedMints: [], + entryCounts: {}, + refusedMints: [], + enabled: true, + createdAt: 1, + ...patch, + }; + db.addCopyTarget(value); + return value; +} +const buyServices = (): CopyBuyServices => ({ + selectWallets: () => [wallet], + getMintDecimals: async () => 6, + getMintBalances: async () => new Map([[wallet.address, 0n]]), + screenToken: async () => ({ verdict: { safe: true, reasons: [], notes: [] } }), + batchPumpTrade: async () => filled(), + measureTokensGained: async () => 100, +}); +function context() { + const text: string[] = []; + const keyboards: InlineKeyboard[] = []; + const ctx = { + from: { id: 1 }, + reply: async (message: string, options?: { reply_markup?: InlineKeyboard }) => { + text.push(message); + if (options?.reply_markup) keyboards.push(options.reply_markup); + return {}; + }, + answerCallbackQuery: async () => true, + } as unknown as Context; + return { ctx, text, keyboards }; +} + +function programAccountsFixture( + accounts: GetProgramAccountsResponse, + onRead = () => {}, +): typeof client.getProgramAccounts { + function read( + program: PublicKey, + options: GetProgramAccountsConfig & { withContext: true }, + ): Promise>; + function read( + program: PublicKey, + options?: Commitment | GetProgramAccountsConfig, + ): Promise; + async function read( + _program: PublicKey, + options?: Commitment | GetProgramAccountsConfig, + ): Promise> { + onRead(); + return typeof options === 'object' && options.withContext + ? { context: { slot: 1 }, value: accounts } + : accounts; + } + return read; +} + +/** Stub every remote boundary used by token cards, leaving assembly/classification real. */ +function mockToken(mint: string): void { + const mintData = Buffer.alloc(82); + mintData.writeBigUInt64LE(1000n, 36); + mintData[44] = 0; + mintData[45] = 1; + client.getAccountInfo = async key => + key.toBase58() === mint + ? { owner: TOKEN_PROGRAM_ID, data: mintData, executable: false, lamports: 1, rentEpoch: 0 } + : null; + client.getTokenSupply = async () => ({ + context: { slot: 1 }, + value: { + amount: '1000', + decimals: 0, + uiAmount: 1000, + uiAmountString: '1000', + }, + }); + client.getTokenLargestAccounts = async () => ({ context: { slot: 1 }, value: [] }); + client.getMultipleAccountsInfo = async keys => keys.map(() => null); + client.getProgramAccounts = programAccountsFixture([]); + globalThis.fetch = async (input, init) => { + const url = String(input); + if (url.includes('dexscreener')) + return new Response( + JSON.stringify([ + { + chainId: 'solana', + dexId: 'fixture', + baseToken: { address: mint, name: 'Fixture', symbol: 'F' }, + priceUsd: '0.01', + liquidity: { usd: 10_000 }, + volume: { h1: 10_000 }, + pairCreatedAt: Date.now() - 600_000, + }, + ]), + ); + if (url.includes('/tokens/v2/search')) return new Response('[]'); + if (init?.method === 'POST') + return new Response(JSON.stringify({ result: { accounts: [], paginationKey: null } })); + return new Response('{}'); + }; +} + +export { + dataDir, + db, + vault, + wallets, + BASE_FEE_LAMPORTS, + fire, + mirrorBuy, + mirrorSell, + armCopyRules, + getTokenInfo, + readTokenLocks, + createNotifier, + createBot, + session, + handlers, + ui, + rebuildPnl, + batchSweepSol, + exitReserveLamports, + reviewFeedHealth, + queueEvictionIndex, + client, + originalFetch, + originalSetTimeout, + originalNow, + check, + deferred, + settle, + wallet, + filled, + rejected, + target, + buyServices, + context, + programAccountsFixture, + mockToken, +}; +export const passCount = () => passed; diff --git a/scripts/behaviors/storage.ts b/scripts/behaviors/storage.ts new file mode 100644 index 0000000..1a8ac39 --- /dev/null +++ b/scripts/behaviors/storage.ts @@ -0,0 +1,159 @@ +/** Offline public-behavior regressions. No source-code matching or live services. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Keypair, PublicKey, SystemProgram, VersionedTransaction } from '@solana/web3.js'; +import type { AutoRule } from '../../src/store/db.js'; + +import { + dataDir, + db, + vault, + wallets, + BASE_FEE_LAMPORTS, + armCopyRules, + handlers, + batchSweepSol, + exitReserveLamports, + client, + check, + target, + context, + mockToken, +} from './fixtures.js'; + +export async function runStorageBehaviors(): Promise { + // Exercise the actual durable-write API with observed filesystem calls. + { + const file = path.join(dataDir, 'atomic-fixture'); + const order: string[] = []; + const sync = fs.fsyncSync; + const rename = fs.renameSync; + fs.fsyncSync = fd => { + order.push('sync'); + sync(fd); + }; + fs.renameSync = (from, to) => { + order.push('rename'); + rename(from, to); + }; + try { + vault.writeAtomic(file, 'first'); + vault.writeAtomic(file, 'latest'); + } finally { + fs.fsyncSync = sync; + fs.renameSync = rename; + } + assert.ok(order.indexOf('sync') < order.indexOf('rename')); + assert.equal(fs.readFileSync(file, 'utf8'), 'latest'); + assert.equal(fs.readFileSync(`${file}.bak`, 'utf8'), 'latest'); + fs.renameSync = () => { + throw new Error('injected rename failure'); + }; + try { + assert.throws(() => vault.writeAtomic(file, 'failed'), /injected rename/); + } finally { + fs.renameSync = rename; + } + assert.equal(fs.readFileSync(file, 'utf8'), 'latest'); + assert.equal(fs.readFileSync(`${file}.bak`, 'utf8'), 'latest'); + assert.equal(fs.existsSync(`${file}.${process.pid}.tmp`), false); + check( + 'atomic writes sync before replacement, keep the current backup and clean up failed replacements', + ); + } + { + db.wipe(); + const half = target({ takeProfitPct: 20, takeProfitSellPct: 50 }); + const custom = target({ takeProfitPct: 20, takeProfitSellPct: 75 }); + const rule = (id: string, patch: Partial = {}): AutoRule => ({ + id, + mint: id, + kind: 'take_profit', + triggerPct: 20, + sellPercent: 50, + enabled: true, + createdAt: 1, + ...patch, + }); + db.addRule(rule('legacy-half')); + db.addRule(rule('already-fired', { firedAt: 1 })); + db.addRule(rule('custom-exit', { sellPercent: 75 })); + db.reload(); + assert.equal(db.copyTargets().find(t => t.id === half.id)?.takeProfitSellPct, 100); + assert.equal(db.copyTargets().find(t => t.id === custom.id)?.takeProfitSellPct, 75); + assert.equal(db.raw().rules.find(r => r.id === 'legacy-half')?.sellPercent, 100); + assert.equal(db.raw().rules.find(r => r.id === 'already-fired')?.sellPercent, 50); + assert.equal(db.raw().rules.find(r => r.id === 'custom-exit')?.sellPercent, 75); + const defaults = target({ takeProfitPct: 20 }); + armCopyRules(defaults, 'new-default'); + assert.equal(db.rulesFor('new-default')[0]?.sellPercent, 100); + const mint = Keypair.generate().publicKey.toBase58(); + mockToken(mint); + db.recordBuy(mint, { + solSpent: 0.05, + fills: 1, + tokensBought: 100, + costSol: 0.05, + freshEntry: true, + decimals: 0, + }); + await handlers.addAutoRule(context().ctx, mint, 'take_profit', 20); + assert.equal(db.rulesFor(mint)[0]?.sellPercent, 100); + check( + 'loaded half-exit defaults migrate while custom/fired exits survive and new rules exit completely', + ); + } + + { + db.wipe(); + const sender = wallets.generateSolanaWallet('sweep-fixture'); + const destination = Keypair.generate().publicKey.toBase58(); + const balance = 1_000_000_000n; + db.updateSettings({ + priorityFeeSol: 0.00005, + sweepReserveSol: 0.002, + executionMode: 'parallel', + }); + client.getBalance = async () => Number(balance); + client.getLatestBlockhash = async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }); + client.getSignatureStatuses = async () => ({ + context: { slot: 1 }, + value: [ + { + slot: 1, + confirmations: 1, + err: null, + confirmationStatus: 'confirmed', + }, + ], + }); + let sent = 0n; + client.sendRawTransaction = async bytes => { + const tx = VersionedTransaction.deserialize(Uint8Array.from(bytes)); + const transfer = tx.message.compiledInstructions.find(ix => + tx.message.staticAccountKeys[ix.programIdIndex]?.equals(SystemProgram.programId), + ); + assert.ok(transfer); + sent = Buffer.from(transfer.data).readBigUInt64LE(4); + return 'offline'; + }; + client.getParsedTokenAccountsByOwner = async () => ({ context: { slot: 1 }, value: [] }); + await batchSweepSol([sender], destination); + const emptyRemainder = balance - sent - BigInt(BASE_FEE_LAMPORTS) - 50_000n; + assert.equal(emptyRemainder, 2_000_000n); + client.getParsedTokenAccountsByOwner = async () => { + throw new Error('unreadable holdings'); + }; + await batchSweepSol([sender], destination); + const unknownRemainder = balance - sent - BigInt(BASE_FEE_LAMPORTS) - 50_000n; + assert.ok(unknownRemainder >= exitReserveLamports(0.0002, 0, { holdsTokens: true })); + assert.ok(unknownRemainder > emptyRemainder); + check( + 'sweeps honor the configured reserve and keep the token exit floor when holdings are unknown', + ); + } +} diff --git a/scripts/behaviors/token.ts b/scripts/behaviors/token.ts new file mode 100644 index 0000000..4398dc4 --- /dev/null +++ b/scripts/behaviors/token.ts @@ -0,0 +1,197 @@ +/** Offline public-behavior regressions. No source-code matching or live services. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { Keypair, PublicKey, SystemProgram } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; + +import { + db, + getTokenInfo, + readTokenLocks, + client, + originalSetTimeout, + check, + deferred, + settle, + programAccountsFixture, + mockToken, +} from './fixtures.js'; + +export async function runTokenBehaviors(): Promise { + // Multiple pages must contribute to the result; a still-open fifth page is + // unknown, not a complete partial answer. Fallback invokes the real RPC API. + { + const mint = Keypair.generate().publicKey.toBase58(); + const now = Date.now(); + mockToken(mint); + const stream = Buffer.from( + fs.readFileSync('scripts/fixtures/streamflow-stream.b64', 'utf8').trim(), + 'base64', + ); + stream.writeBigUInt64LE(100n, 417); + stream.writeBigUInt64LE(0n, 17); + stream.writeBigUInt64LE(BigInt(Math.floor(now / 1000) + 100), 33); + stream.writeBigUInt64LE(BigInt(Math.floor(now / 1000)), 409); + const pages: (string | undefined)[] = []; + globalThis.fetch = async (_input, init) => { + const request = JSON.parse(String(init?.body)); + pages.push(request.params[1].paginationKey); + return new Response( + JSON.stringify({ + result: { + accounts: [{ account: { data: [stream.toString('base64'), 'base64'] } }], + paginationKey: pages.length === 1 ? 'next-page' : null, + }, + }), + ); + }; + const locks = await readTokenLocks(mint, { now }); + assert.deepEqual(pages, [undefined, 'next-page']); + assert.equal(locks?.locked.length, 2); + assert.equal( + locks?.locked.reduce((sum, item) => sum + item.pct, 0), + 20, + ); + let calls = 0; + globalThis.fetch = async () => { + calls++; + return new Response(JSON.stringify({ result: { accounts: [], paginationKey: 'more' } })); + }; + assert.equal(await readTokenLocks(mint), undefined); + assert.equal(calls, 5); + let fallback = 0; + client.getProgramAccounts = programAccountsFixture( + [ + { + pubkey: Keypair.generate().publicKey, + account: { + data: stream, + owner: PublicKey.default, + executable: false, + lamports: 1, + rentEpoch: 0, + }, + }, + ], + () => { + fallback++; + }, + ); + globalThis.fetch = async () => + new Response(JSON.stringify({ error: { code: -32601, message: 'method unavailable' } })); + assert.equal((await readTokenLocks(mint, { now }))?.locked.length, 1); + assert.equal(fallback, 1); + check( + 'lock scans combine pages, refuse incomplete pagination and fall back on unsupported RPCs', + ); + } + { + db.wipe(); + const mint = Keypair.generate().publicKey.toBase58(); + mockToken(mint); + const pool = Keypair.generate().publicKey; + const whale = Keypair.generate().publicKey; + const accounts = [Keypair.generate().publicKey, Keypair.generate().publicKey]; + client.getTokenLargestAccounts = async () => ({ + context: { slot: 1 }, + value: accounts.map((address, i) => ({ + address, + amount: i === 0 ? '800' : '200', + decimals: 0, + uiAmount: i === 0 ? 800 : 200, + uiAmountString: i === 0 ? '800' : '200', + })), + }); + client.getMultipleParsedAccounts = async () => ({ + context: { slot: 1 }, + value: [pool, whale].map(owner => ({ + data: { + program: 'spl-token', + parsed: { info: { mint, owner: owner.toBase58() } }, + space: 165, + }, + owner: TOKEN_PROGRAM_ID, + executable: false, + lamports: 1, + rentEpoch: 0, + })), + }); + for (const program of [ + 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA', + '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + ]) { + client.getMultipleAccountsInfo = async keys => + keys.map(key => ({ + owner: key.equals(pool) ? new PublicKey(program) : SystemProgram.programId, + data: Buffer.alloc(0), + executable: false, + lamports: 1, + rentEpoch: 0, + })); + const info = await getTokenInfo(mint, 'solana'); + assert.equal(info.holders?.find(holder => holder.owner === pool.toBase58())?.tag, 'pool'); + assert.equal(info.top10Pct, 20); + assert.equal(info.top10PctUpperBound, 20); + } + check( + 'PumpSwap and Raydium liquidity are excluded from holder concentration after RPC classification', + ); + } + { + const timers: { callback: () => void; ms: number }[] = []; + // Keep time under the test's control: no multi-second wall-clock waits. + globalThis.setTimeout = ((callback: () => void, ms = 0) => { + const entry = { callback, ms }; + timers.push(entry); + return { unref: () => entry }; + }) as unknown as typeof setTimeout; + try { + const pendingLargest = deferred>>(); + const fastMint = Keypair.generate().publicKey.toBase58(); + mockToken(fastMint); + let reads = 0; + client.getTokenLargestAccounts = () => { + reads++; + return pendingLargest.promise; + }; + let fastDone = false; + const fast = getTokenInfo(fastMint, 'solana', { fast: true }).then(value => { + fastDone = true; + return value; + }); + await settle(); + assert.equal(reads, 1); + assert.equal(fastDone, false); + for (const timer of timers.filter(timer => timer.ms <= 1500)) timer.callback(); + await settle(); + assert.equal(fastDone, true); + assert.equal((await fast).holdersUnavailable, true); + timers.length = 0; + const cardMint = Keypair.generate().publicKey.toBase58(); + mockToken(cardMint); + client.getTokenLargestAccounts = () => { + reads++; + return pendingLargest.promise; + }; + let cardDone = false; + const card = getTokenInfo(cardMint, 'solana').then(value => { + cardDone = true; + return value; + }); + await settle(); + for (const timer of timers.filter(timer => timer.ms <= 1500)) timer.callback(); + await settle(); + assert.equal(cardDone, false); + for (const timer of timers.filter(timer => timer.ms <= 4000)) timer.callback(); + await settle(); + assert.equal(cardDone, true); + assert.equal((await card).holdersUnavailable, true); + pendingLargest.resolve({ context: { slot: 1 }, value: [] }); + check( + 'fast token screening still reads chain holders but times out before the human card path', + ); + } finally { + globalThis.setTimeout = originalSetTimeout; + } + } +} diff --git a/scripts/behaviors/ui.ts b/scripts/behaviors/ui.ts new file mode 100644 index 0000000..59b01e5 --- /dev/null +++ b/scripts/behaviors/ui.ts @@ -0,0 +1,180 @@ +/** Offline public-behavior regressions. No source-code matching or live services. */ +import assert from 'node:assert/strict'; +import { Keypair } from '@solana/web3.js'; +import type { InlineKeyboard } from 'grammy'; +import type { Update } from 'grammy/types'; + +import { + db, + wallets, + createBot, + session, + handlers, + ui, + rebuildPnl, + client, + originalNow, + check, + context, + mockToken, +} from './fixtures.js'; + +export async function runUiBehaviors(): Promise { + // Real Telegram middleware/router, with only the transport replaced. + { + db.wipe(); + const bot = createBot(); + bot.botInfo = { + id: 123, + is_bot: true, + first_name: 'Offline', + username: 'offline_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + } as typeof bot.botInfo; + const calls: { method: string; payload: Record }[] = []; + bot.api.config.use(async (_previous, method, payload) => { + const request = payload as Record; + calls.push({ method, payload: request }); + const result = + method === 'sendMessage' || method === 'editMessageText' + ? { + message_id: 10, + date: 0, + chat: { id: request.chat_id, type: 'private' }, + text: request.text, + } + : true; + return { ok: true, result } as never; + }); + let updateId = 0; + const callback = (data: string): Update => ({ + update_id: ++updateId, + callback_query: { + id: `q-${updateId}`, + chat_instance: 'offline', + data, + from: { id: 1, is_bot: false, first_name: 'Owner' }, + message: { message_id: 11, date: 0, chat: { id: 1, type: 'private', first_name: 'Owner' } }, + }, + }); + const callbackData = (keyboard: InlineKeyboard) => + keyboard.inline_keyboard + .flat() + .flatMap(button => ('callback_data' in button ? [button.callback_data] : [])); + const keyboards = [ + ui.mainMenu(), + ui.portfolioKeyboard(), + ui.pnlKeyboard(), + ui.settingsKeyboard(db.settings()), + ui.copyDecisionsKeyboard(), + ]; + for (const data of new Set(keyboards.flatMap(callbackData))) { + calls.length = 0; + await bot.handleUpdate(callback(data)); + assert.ok( + !calls.some(call => call.payload.text === 'Unknown action.'), + `${data} must reach a route`, + ); + assert.ok(calls.length > 0, `${data} must respond`); + } + check( + 'public navigation keyboards reach actual callback handlers through the authenticated bot', + ); + session.setPending(1, { kind: 'custom_buy', mint: 'old-mint' }); + await bot.handleUpdate(callback('copy_add')); + assert.equal(session.takePending(1)?.kind, 'copy_address'); + session.setPending(1, { kind: 'custom_buy', mint: 'old-mint' }); + await bot.handleUpdate(callback('home')); + assert.equal(session.takePending(1), undefined); + check('callback navigation clears stale prompts before installing a new prompt'); + const mint = Keypair.generate().publicKey.toBase58(); + mockToken(mint); + session.setPending(1, { kind: 'custom_buy', mint: 'old-mint' }); + calls.length = 0; + await bot.handleUpdate({ + update_id: ++updateId, + message: { + message_id: updateId, + date: 0, + from: { id: 1, is_bot: false, first_name: 'Owner' }, + chat: { id: 1, type: 'private', first_name: 'Owner' }, + text: mint, + }, + }); + assert.equal(session.session(1).lastTokenMint, mint); + assert.ok(calls.some(call => String(call.payload.text).includes('Fixture'))); + assert.equal(session.takePending(1), undefined); + session.setPending(1, { kind: 'copy_address' }); + Date.now = () => originalNow() + 6 * 60_000; + try { + assert.equal(session.takePending(1), undefined); + } finally { + Date.now = originalNow; + } + check('mint pastes override numeric prompts and abandoned prompts expire'); + db.recordCopyDecision({ + at: Date.now(), + target: '', + mint: '', + reason: '', + }); + const rendered = ui.renderCopyDecisions(db.copyDecisions()); + assert.ok(rendered.includes('<mint>')); + const copyScreen = context(); + await handlers.showCopyTrade(copyScreen.ctx); + assert.ok( + copyScreen.keyboards.some(keyboard => + keyboard.inline_keyboard + .flat() + .some( + button => + 'callback_data' in button && + button.callback_data === 'copy_decisions' && + button.text.includes('(1)'), + ), + ), + ); + const safetyScreen = context(); + await handlers.showCopySafety(safetyScreen.ctx); + assert.ok( + safetyScreen.keyboards.every(keyboard => !callbackData(keyboard).includes('safety_lock')), + ); + assert.ok(safetyScreen.text.every(text => !text.includes('Ignore supply locked'))); + calls.length = 0; + await bot.handleUpdate(callback('safety_lock')); + assert.ok(!calls.some(call => call.payload.text === 'Unknown action.')); + check( + 'skip history stays reachable with full escaped mints and obsolete lock controls remain compatible', + ); + } + { + db.wipe(); + wallets.generateSolanaWallet('reconciliation-fixture'); + client.getSignaturesForAddress = async () => [ + { + signature: 'unreadable', + slot: 1, + err: null, + memo: null, + blockTime: Math.floor(Date.now() / 1000), + confirmationStatus: 'confirmed', + }, + ]; + client.getParsedTransactions = async () => [null]; + const incomplete = context(); + await rebuildPnl(incomplete.ctx); + assert.ok( + incomplete.text.some(text => /Only part of the history|Nothing could be read/.test(text)), + ); + assert.ok(incomplete.text.every(text => !text.includes('Nothing was missing'))); + client.getSignaturesForAddress = async () => []; + const complete = context(); + await rebuildPnl(complete.ctx); + assert.ok(complete.text.some(text => text.includes('Nothing was missing'))); + check( + 'reconciliation UI reports incomplete reads and reserves the all-clear for a complete scan', + ); + } +} diff --git a/scripts/behaviors/watcher.ts b/scripts/behaviors/watcher.ts new file mode 100644 index 0000000..a02fa25 --- /dev/null +++ b/scripts/behaviors/watcher.ts @@ -0,0 +1,128 @@ +/** Offline public-behavior regressions. No source-code matching or live services. */ +import assert from 'node:assert/strict'; +import type { AutoRule } from '../../src/store/db.js'; +import type { WatcherTradeServices } from '../../src/services/watcher.js'; + +import { db, fire, createNotifier, check, wallet, filled, rejected } from './fixtures.js'; + +export async function runWatcherBehaviors(): Promise { + // These complement the filled/uncertain/rejected cases in copytrade-regressions. + for (const kind of ['stop_loss', 'trailing_stop', 'take_profit', 'limit_buy'] as const) { + db.wipe(); + db.updateSettings({ slippagePercent: 15 }); + const rule: AutoRule = { + id: `selection-${kind}`, + mint: 'watcher-mint', + symbol: '