Event-driven + schedule-based contract automation for P2P.me on Base. Listens to on-chain events, runs scheduled jobs (order sweeper, order scanner), and executes contract calls via dedicated executor wallets. Uses ethers.js v6, BullMQ and Redis. Runs as a private worker (Railway or Docker Compose) with a Redis sidecar; the only thing it serves is a /healthz liveness probe.
- What it does
- Architecture
- Wallet management
- Environment variables
- Dry run mode
- Quick start (local)
- How it works
- Deployment
- License
- Event listening — Subscribes to the Diamond contract on Base (WebSocket). On
OrderPlaced, immediately enqueuesToggleMerchantsOfflineand enqueuesAssignMerchantsafter a configurable delay. OnOrderCompleted(when the B2B cashback programme is enabled), enqueuesIssueCashbackCreditfor eligible non-B2B BUY and SELL orders. - Order sweeper — Every 1 minute: batch-checks all tracked orders via Multicall3, untracking completed/cancelled ones and auto-cancelling expired ones.
- Order scanner — Every 1 hour: rescans the last 2 500 blocks to catch any orders the WS listener may have missed.
- B2B cashback programme — Opt-in via env. Credits a bps cut of every completed non-B2B BUY or SELL back to the order's user (buyer for BUY, seller for SELL) via
cashbackIntegrator.issueCredit(user, amount), signed by a dedicatedcashbackwallet that must be whitelisted on the integrator (setCreditIssuer(cashbackWallet, true)). - Auto-funded wallets — Five subwallets (toggle, assign, sweeper, cashback, keeper) are managed automatically. The funding wallet tops them up whenever any drops below the minimum balance. Discord alerts go to three dedicated channels (success / fail / balance).
- Liveness probe —
GET /healthzand nothing else. There is no API.
src/
├── listeners/ # OrderPlaced WS → enqueue toggle + assign jobs
├── schedulers/ # Repeating BullMQ jobs (sweeper every 1m, scanner every 1h)
├── queue/
│ ├── index.ts # Queue definitions + addToggleJob / addAssignJob helpers
│ ├── handlers.ts # Business logic: toggleMerchantsOffline, assignMerchants
│ └── workers/ # BullMQ workers: toggle, assign, orderSweeper, orderScanner
├── helpers/
│ ├── safeSend.ts # All contract writes go here (presim → send → wait → alert)
│ ├── walletManager.ts# Subwallet lifecycle: load from env, auto-fund
│ ├── discord.ts # sendDiscordAlert (3 channel webhooks)
│ ├── multicall.ts # Multicall3 helper (batch RPC reads into 1 eth_call)
│ ├── config.ts # loadExecutorConfig() — fails fast on missing env
│ ├── provider.ts # Alchemy HTTP/WS providers, withTimeout
│ └── abi.ts # Diamond contract ABI
├── utils/
│ ├── orderTracker.ts # Redis set: track/untrack/sync active order IDs
│ └── fetchPendingOrders.ts # getLogs → Multicall3 batch status check
└── index.ts # Bootstrap: config → wallets → workers → listeners → schedulers
All contract writes go through safeSend(). It handles pre-simulation, sending, confirmation wait, nonce resets, and Discord alerts. When DRY_RUN=true it only runs staticCall — no transaction is ever sent.
The executor uses five subwallets (toggle, assign, sweeper, cashback, keeper) plus one funding wallet.
Every signing key is read from an env var at boot. The executor never generates a wallet at runtime and never writes a key to Redis, disk, or logs — your platform's secret manager is the single source of truth. A missing key is a hard boot failure.
TOGGLE_EXECUTOR=0x...privatekey
ASSIGN_EXECUTOR=0x...privatekey
ORDER_SWEEPER_EXECUTOR=0x...privatekey
CASHBACK_EXECUTOR=0x...privatekey
KEEPER_EXECUTOR=0x...privatekey
FUNDING_EXECUTOR=0x...privatekeyRedis is used for the BullMQ job queues and the tracked-order set only. It holds no secrets.
Update the env var and redeploy. The new key is used immediately.
FUNDING_EXECUTOR holds the float. The funding wallet tops up any subwallet that drops below MIN_BASE_BALANCE_ETH (default 0.005 ETH), sending enough to bring it to 0.02 ETH. On every boot and every 10 minutes, balances are checked and Discord alerts are sent to the balance channel.
On first boot, Discord (success channel) receives all wallet addresses so you know what to fund.
Copy .env.example to .env for local dev. In production set these in the platform's secret manager (Railway variables / compose .env on the host) — never in a committed file.
| Variable | Description |
|---|---|
ALCHEMY_API_KEY |
Alchemy API key for Base mainnet |
DIAMOND_ADDRESS |
Diamond contract address on Base |
FUNDING_EXECUTOR |
Private key of the funding wallet (tops up subwallets) |
DISCORD_ONSUCCESS_WEBHOOK_URL |
Discord webhook — successful tx alerts |
DISCORD_ONFAIL_WEBHOOK_URL |
Discord webhook — failed tx / WS error alerts |
DISCORD_BALANCE_WEBHOOK_URL |
Discord webhook — balance + auto-fund alerts |
ASSIGN_DELAY_IN_SECONDS |
Seconds to wait before assigning merchants after OrderPlaced (e.g. 90) |
| Variable | Description |
|---|---|
REDIS_URL |
Default: redis://redis:6379. Use redis://localhost:6379 for local dev |
MIN_BASE_BALANCE_ETH |
Minimum subwallet ETH before auto-fund kicks in (default 0.005) |
LOG_LEVEL |
debug / info / warn / error (default info) |
DRY_RUN |
true to simulate only — no transactions sent (default false) |
PORT |
HTTP port (default 8000) |
CASHBACK_INTEGRATOR_ADDRESS |
B2B cashback programme: integrator address to call issueCredit on. Leave unset (or set CASHBACK_BPS=0) to disable the programme entirely — the OrderCompleted listener silently no-ops. |
CASHBACK_BPS |
B2B cashback programme: bps of each completed non-B2B BUY or SELL amount to credit (e.g. 200 = 2%). Range [0, 10000]. 0 disables the programme. |
All five are mandatory; the executor refuses to boot if any is missing.
| Variable | Description |
|---|---|
TOGGLE_EXECUTOR |
Private key for the toggle wallet |
ASSIGN_EXECUTOR |
Private key for the assign wallet |
ORDER_SWEEPER_EXECUTOR |
Private key for the sweeper wallet |
CASHBACK_EXECUTOR |
Private key for the cashback wallet. Its address must be whitelisted on the cashback integrator via setCreditIssuer(addr, true) before issueCredit calls land — the startup Discord message prints the address explicitly so you know what to whitelist. |
KEEPER_EXECUTOR |
Private key for the daily keeper wallet (approveUnstakeBatch + blacklistInactiveMerchants) |
Set DRY_RUN=true to run against mainnet without spending gas or changing state.
| Component | Behaviour in dry run |
|---|---|
safeSend (all contract writes) |
Runs staticCall only — no sendTransaction |
checkAndFund (wallet auto-top-up) |
Logs [DRY_RUN] Would auto-fund ... — no ETH sent |
Initial syncOrderIds (10k block scan) |
Skipped entirely |
| WS listener + job queueing | Works normally — jobs execute but hit safeSend dry-run path |
Use this to verify your config and contract state before going live. You'll see safeSend[DRY_RUN]: simulated fn=... ok or simulation failed in logs.
cp .env.example .env
# fill in ALCHEMY_API_KEY, DIAMOND_ADDRESS, FUNDING_EXECUTOR, Discord webhooks, ASSIGN_DELAY_IN_SECONDS
# Option A — Docker (recommended)
./test.sh
# Option B — host Redis + local app
docker run -d -p 6379:6379 redis:7-alpine
REDIS_URL=redis://localhost:6379 npm run devHealth check: GET http://localhost:8000/healthz
WebSocket (Alchemy)
└─ OrderPlaced event
├─ trackOrderId in Redis
├─ enqueue ToggleMerchantsOffline (immediate)
│ └─ toggleWorker → getNonEligibleMerchantsByCircleId
│ → removeNonEligibleMerchantsByCircleId (if any found)
│ (30s cooldown per circleId — atomic Redis SET NX)
└─ enqueue AssignMerchants (after ASSIGN_DELAY_IN_SECONDS)
└─ assignWorker → getOrdersById (still PLACED?)
→ assignMerchants (if still PLACED)
Every 1 min — OrderSweeper
└─ getTrackedOrderIds from Redis
└─ Multicall3 batch: getOrdersById + isOrderExpired (2N → 1 eth_call)
└─ untrack completed/cancelled orders
└─ autoCancelExpiredOrders in batches of 20
Every 1 hour — OrderScanner
└─ getLogs for OrderPlaced (last 2 500 blocks)
└─ Multicall3 batch: getOrdersById for all found orders
└─ union active IDs into Redis tracking set
safeSend()
├─ DRY_RUN? → staticCall only, return
├─ !skipPresim? → staticCall, return false on revert (with Discord alert)
├─ sendTransaction → if NONCE_EXPIRED/REPLACEMENT_UNDERPRICED: NonceManager.reset()
└─ tx.wait(1) with 3min timeout
├─ timeout: check receipt directly, alert, return false
├─ reverted: Discord fail alert, return false
└─ confirmed: Discord success alert, return true
See deploy/vps/DEPLOY.md for full step-by-step instructions.
# On the server
mkdir ~/executor && cd ~/executor
nano docker-compose.yml # paste deploy/vps/docker-compose.yml
nano .env # paste deploy/vps/.env.example, fill in all values
docker compose pull && docker compose up -d
docker compose logs -f executor# Bump TAG in build_and_push.sh, then:
./build_and_push.sh
# Update the image tag in ~/executor/docker-compose.yml on the server, then:
docker compose pull && docker compose up -dThe executor exposes no service anyone else consumes — deploy it without a public
domain. The VPS compose file binds the HTTP port to 127.0.0.1 for the same reason,
and Redis is reachable only on the internal network.
MIT. See LICENSE.