A local desktop bot for the QuantumRaffleV2 contract on Ethereum mainnet. Rust and React, packaged with Tauri 2.
Your key is generated, encrypted, and used to sign entirely on your own machine. There is no account, no cloud, and no backend, and the key itself is never sent anywhere. The one deliberate way it leaves the Rust process is Export, which re-prompts for your passphrase every time.
Built to docs/quantum-bot-prd.md. Where the
implementation departs from that spec, and where the spec turned out to be
wrong, is recorded in docs/findings.md.
The strategy this kind of bot is usually built around does not work.
Entering the raffle does not secure a win. It resets the full inactivity timer and makes you the one to beat for another 600 seconds. It is not a snipe. It is a bet that nobody responds.
We measured whether anybody responds. Across all 147 entries in games 1 to 3:
In 90 of 90 cases, a rival re-entered before the timer expired. A last-second entry has won 0% of the time.
The median gap between entries is 540 seconds against a 600 second deadline, which is the signature of a rival bot re-entering deliberately, just before each deadline. So the app defaults to simulation mode, and automatic mode is gated behind an explicit acknowledgement that shows you this number first.
Reproduce it yourself. This replays every GameEntered log since the deploy
block, so it needs an archive endpoint with a key. The keyless default will rate
limit partway through and abort:
node scripts/analyze-history.mjs "https://mainnet.infura.io/v3/<key>"Not production ready. Feature complete, and verified everywhere it can be verified without credentials or elapsed time. The milestones below are the exit gates from the spec.
| Milestone | State |
|---|---|
| M1 Read-only | Verified 5/5 against live mainnet: live config, isWinner on every ticket of the open game, fee history, ticket reconstruction. A 24 hour soak is outstanding and needs a keyed RPC. |
| M2 Simulator | Answered, and it failed the feature. 90/90 re-entry, so automatic mode ships gated rather than on. The gate failing is the gate working. |
| M3 Execution | Verified 6/6 on a Foundry fork, including a real signed entry the chain confirms. The spec forbids mainnet first. |
| M4 Background | Verified 3/3 over a websocket fork: tick on new heads, cancellation, backoff. A 72 hour unattended soak is outstanding. |
| M5 Distribution | Blocked on an Apple Developer ID certificate and an update signing key. The updater plugin is deliberately not registered until both exist, because a placeholder key reads as configured while protecting nothing. |
- Rust 1.82 or newer
- Node 20.19+ or 22.12+ (the floor Vite 7 sets), and pnpm
- macOS 10.15+ or Windows. On macOS you also need the Xcode Command Line Tools.
- An archive capable Ethereum RPC endpoint with WebSocket support
The archive requirement is not optional. Ticket ownership can only be recovered
from GameEntered logs, because the contract exposes no ticketsOf(address)
view, so the bot has to read log history. Measured against real providers
(docs/findings.md §9):
| Provider | Works? |
|---|---|
| Infura, dRPC | Yes |
| Alchemy free tier | No. 10 block cap on eth_getLogs |
| publicnode | No. Archive requests need a token |
| Cloudflare | No. Rejects the range as too large |
pnpm install
pnpm tauri devThen set your RPC endpoint in Settings and press Test connection before starting the bot.
Everything the app needs is set in the UI. Two things are worth knowing about what happens on disk.
Where your data lives. On macOS, under
~/Library/Application Support/com.quantumbot.desktop/:
| File | Contents |
|---|---|
vault.json |
Your encrypted key. Written atomically, fsynced, 0600. Nothing else has a copy. |
settings.json |
Plaintext configuration and spend accounting. Never secrets. |
Back up vault.json. If you lose it, the key is gone. The app refuses to
create a wallet over a vault file it cannot read, rather than silently replacing
it, so a corrupt file is recoverable from a backup and unrecoverable without one.
QUANTUM_BOT_TEST_RPC points the scripts, the fork, and the live tests at an
endpoint of your choice. It takes an HTTP URL, unlike the app's own endpoint,
which is a WebSocket one. Set it: the fallbacks are keyless public endpoints that
rate limit, and they will make the network tests flake rather than fail honestly.
export QUANTUM_BOT_TEST_RPC="https://mainnet.infura.io/v3/<key>"Each script also takes the URL as its first argument, which overrides this.
The default run is fast and offline. Everything that touches the network or
needs a fork is #[ignore]d, so nothing in a normal run can reach the chain.
cd src-tauri && cargo test --lib # 172 unit tests, no network
pnpm check # tsc, plus the Rust to TypeScript contractpnpm check is the one guarding the seam. It regenerates src/generated/ from
the Rust types and fails if the checked-in files move, so a change to a DTO
cannot land without the TypeScript following it. Generation only helps if drift
breaks a build: for a while this repo generated those files and imported none of
them, and the types drifted anyway.
The opt-in suites, in rising order of what they touch:
# Read-only checks against live mainnet (M1)
cargo test --test live_mainnet -- --ignored --nocapture
# The full RFC 9106 2 GiB vault derivation (~3s in release, ~50s in debug)
cargo test --release --lib -- --ignored
# End-to-end entry on a local fork (M3). The only tests that sign anything.
./scripts/local-fork.sh # in one terminal
cargo test --test fork_entry -- --ignored --nocapture --test-threads=1
cargo test --test runtime_loop -- --ignored --test-threads=1Never point the signing tests at mainnet. scripts/local-fork.sh passes
--chain-id 1 so the fork presents as mainnet and the bot's allowlist accepts
it, which is exactly what makes it a useful test and exactly what would make a
mistake here expensive.
Verify the contract bindings against the live deployment:
node scripts/verify-abi.mjs # ABI shape and revert selectors vs the chainsrc/ React + TypeScript. Renders state, sends intent.
generated/ Wire types, generated from the Rust structs. Do not edit.
components/ Dashboard, settings, onboarding, activity feed.
lib/ IPC wrapper, formatting, copy.
src-tauri/src/
security/ vault (argon2id + XChaCha20-Poly1305), signer, hygiene
blockchain/ bindings, atomic reader, ticket index, fees, submitter
bot/ types, strategy, policy, runtime
activity/ structured log
commands.rs the entire IPC surface
state.rs app state, including the spend journal
storage.rs vault and settings on disk
Three rules shape the whole thing.
The frontend is untrusted. It renders state and sends intent. It holds no
key, signs nothing, and never talks to Ethereum. The CSP's connect-src is
ipc: http://ipc.localhost and omits every RPC origin, so this is enforced
rather than merely intended. The webview's capability grant is core:default
and nothing else.
Strategy decides what is desirable. Policy decides what is allowed. They are
separate modules that cannot import each other, and policy always runs last.
This is enforced by the type system rather than by convention:
policy::approve is the only constructor of PolicyApproval, and
SignerHandle::sign_entry requires one. Code that skips policy does not fail
review, it fails to compile.
Fail closed. Any uncertainty, whether a stale read, an unknown revert, a refused log scan, or a chain ID mismatch, skips the tick. A missed entry costs nothing. A wrong entry costs money.
The countdown does not exist during warm-up. A game cannot end until it has
minTicketsBeforeEnd tickets (currently 25), no matter how much time passes.
Any countdown shown before then is fiction. Rather than guard against displaying
one, the Phase type simply has no field for it outside Phase::Live, so the
bug cannot be written.
Winning tickets are provisional. Every new entry shifts every ticket's position from the end, so tickets drift in and out of winning slots continuously. Only the state at game end pays, and the UI says so.
The vault is argon2id (RFC 9106 first recommended: 2 GiB, t=1, p=4) over XChaCha20-Poly1305, falling back to the second recommended profile (64 MiB, t=3) only if 2 GiB cannot be allocated, and recording which profile it used. That protects your key against disk theft, backups, and stray copies.
It does not protect against software already running as you. The bot must decrypt to sign, so anything with your user account can get the key. No storage design fixes that.
What actually protects you is blast radius, and it is mostly things you do:
- Use a dedicated hot wallet, funded only with working capital.
- Route prizes to a cold wallet. This is the single most effective control here, and it is off until you set an address, because there is no sensible default: only you know your cold wallet. The dashboard warns until you set one. Once set, the hot key can never touch the winnings, even if it leaks later.
- Keep the per-game and per-day spend caps meaningful. They are enforced by policy, persisted before any transaction is broadcast, and survive a restart.
One known gap, tracked under M5. security/hygiene.rs disables core dumps at
startup, which works on any Unix and means a crash cannot write your key into
/cores. It cannot stop a same-uid process from attaching a debugger: on Linux
that is PR_SET_DUMPABLE, and macOS has no runtime equivalent. The macOS answer
is the get-task-allow entitlement being absent from a signed, hardened-runtime
build, which is a property of the bundle rather than something the process can
set about itself. Builds are not signed yet, so that protection is not in force,
and an unsigned download would fail Gatekeeper anyway. Run from source until it
lands.
This software signs financial transactions on Ethereum mainnet on your behalf. It is unaffiliated with the authors or operators of the QuantumRaffleV2 contract. It is provided as is, with no warranty. You are responsible for the funds in any wallet you give it. Read the strategy note at the top of this file before you enable automatic mode.
Unpublished. All rights reserved.