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 new file mode 100644 index 0000000..6f9849b --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,24 @@ +name: Check + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +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: ${{ matrix.node }} + cache: npm + - run: npm ci + - 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/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 e61e6a5..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 @@ -380,7 +395,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 +510,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 +529,16 @@ 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 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: + +- `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 @@ -594,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 new file mode 100644 index 0000000..57d93c9 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,70 @@ +# 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 + +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 +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 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. +## Dependency findings + +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/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 a576d95..91017ab 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", @@ -18,11 +19,12 @@ "tsx": "^4.19.2" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "^22.10.2", "typescript": "^5.7.2" }, "engines": { - "node": ">=20.11" + "node": ">=20.18" } }, "node_modules/@babel/runtime": { @@ -34,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", @@ -1288,14 +1453,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 +1718,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 +1729,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 +2002,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 +2120,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 79e518f..d922ba2 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,25 @@ "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": "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 && 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 quality && npm run typecheck && npm test", + "check:live": "npm run check && npm run netcheck", + "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", @@ -28,7 +37,13 @@ "tsx": "^4.19.2" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@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..d0c0703 --- /dev/null +++ b/scripts/accounting-regressions.ts @@ -0,0 +1,505 @@ +/** 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(); +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]) { + 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/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: '