Autonomous DCA Bot with a Budget Conscience Built for the First PinionOS Hackathon · $2,750 prize pool · @PinionOS
A fully autonomous agent that dollar-cost-averages into ETH (or any token) on a configurable schedule. It tracks its own average cost basis across all-time trades, only buys when the current price is below that average (guaranteeing every trade lowers the average), enforces a weekly USDC spend limit, executes real on-chain swaps via 1inch on Base, and narrates every decision in plain English — all powered by three PinionOS skills.
- What It Does
- How PinionOS Powers It
- Architecture
- Project Structure
- Implementation Deep-Dive
- Prerequisites
- Setup and Installation
- Running the Bot
- Testing at the Current Time with --run-now
- Claude MCP Integration
- Configuration Reference
- Cost Basis Strategy
- Trade Lifecycle
- Budget Tracking
- Troubleshooting
Every trade cycle the bot:
- Fetches the live ETH price from Birdeye via
pinion.skills.price() - Guards the weekly budget — refuses to trade if the rolling 7-day USDC limit would be exceeded
- Checks average cost basis — only trades if the current price is below the all-time average entry price, ensuring every trade lowers the average
- Optionally applies an additional price-drop threshold for extra caution (configurable %)
- Calls
pinion.skills.trade()to get a 1inch-routed swap transaction - Broadcasts the swap on Base — sends an ERC-20 approve if needed, then the swap, waits for on-chain confirmation
- Updates the all-time cost basis and then asks
pinion.skills.chat()to write a plain-English explanation including the new average - Persists everything to a local JSON state file that survives restarts
Sample output after a successful cycle:
✅ [Mar 1, 12:00 PM]
Bought $10 ETH at $1,890 — price was 1.9% below avg cost of $1,927.
New average cost basis is now $1,921. $30 of $100 weekly budget used.
Sample output when price is above the average cost basis:
⏭ [Mar 1, 12:00 PM]
ETH is $1,960, which is 1.7% above my avg cost of $1,927. Buying
would raise my average — waiting for a dip below $1,927.
Sample output when the budget is exhausted:
⏭ [Mar 1, 12:00 PM]
Hit my $100 weekly limit — only $3.40 remaining. Will resume when
the budget resets on Mar 8.
PinionOS is a skill infrastructure for autonomous agents. Each skill is an x402-paywalled HTTP endpoint: the SDK handles EIP-3009 payment signing automatically, the facilitator settles USDC on Base, and the skill returns data. Cost: $0.01 USDC per call.
Without PinionOS, building this bot would require:
Without Pinion With Pinion
──────────────────────────────────────────────────────────────
Live token price skills.price("ETH")
• Register Birdeye/CoinGecko API key • One method call
• Handle rate limits + retries • $0.01 USDC per call
• Parse response shape • Response in hand
1inch swap route skills.trade("USDC","ETH","10")
• Register 1inch API key • One method call
• Build quote → route → calldata • $0.01 USDC per call
• Handle slippage, token decimals • Calldata ready to broadcast
Plain-English narration skills.chat("summarise...")
• Set up Anthropic SDK • One method call
• Write system prompt • $0.01 USDC per call
• Handle streaming / errors • Explanation returned
Micropayment layer Handled by PinionClient
• Implement EIP-3009 signing • Just pass privateKey
• Build facilitator integration • SDK handles everything
• Manage USDC approvals + gas
Each full trade cycle uses exactly three skill calls costing $0.03 USDC total:
┌─────────────────────────────────────────────────────────┐
│ Cycle cost breakdown │
│ │
│ skills.price() $0.01 — live ETH/USD from Birdeye │
│ skills.trade() $0.01 — 1inch swap calldata │
│ skills.chat() $0.01 — plain-English explanation │
│ ───── │
│ Total per trade $0.03 USDC in Pinion fees │
└─────────────────────────────────────────────────────────┘
Your Bot PinionOS Server Facilitator
│ │ │
│── skills.price("ETH") ───>│ │
│<── 402 + payment terms ───│ │
│ │ │
│ [SDK signs EIP-3009 auth using your PINION_PRIVATE_KEY]
│ │ │
│── skills.price("ETH") ───>│ │
│ X-PAYMENT: <signed> │─── verify + settle ──────>│
│ │<── confirmed ─────────────│
│<── 200 { priceUSD: 1932 }─│ │
│ │ │
You called one method. The rest was invisible.
┌──────────────────────────────────────────────────────────────────┐
│ Claude Desktop / Claude Code (optional) │
│ MCP host │
└─────────────────────────┬────────────────────────────────────────┘
│ JSON-RPC over stdio (5 tools)
│ ALL logging goes to stderr — never stdout
┌─────────────────────────▼────────────────────────────────────────┐
│ mcp-server.ts │
│ dca_status dca_run_now dca_history dca_set_config │
│ dca_start_stop │
│ Zod-validated inputs · auto-starts scheduler on boot │
└─────────────────────────┬────────────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────────────┐
│ scheduler.ts │
│ node-cron + EventEmitter │
│ fires runCycle() on cron tick or via runNow() │
│ emits: cycle | error | started | stopped │
└─────────────────────────┬────────────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────────────┐
│ agent.ts (PinionDCAAgent) │
│ │
│ ① pinion.skills.price() → live ETH price via Birdeye │
│ ② SpendTracker.canSpend() → weekly budget gate │
│ ③ avgCostBasis guard → skip if price ≥ avg cost │
│ ④ price threshold check → optional secondary dip filter │
│ ⑤ pinion.skills.trade() → 1inch swap calldata │
│ ⑥ ethers.Wallet → approve + swap on Base │
│ ⑦ SpendTracker.update() → record spend + update cost basis │
│ ⑧ pinion.skills.chat() → plain-English explanation │
└──────────────┬───────────────────────────┬───────────────────────┘
│ │
┌──────────────▼───────────┐ ┌────────────▼──────────────────────┐
│ spend-tracker.ts │ │ PinionClient │
│ dca-state.json │ │ x402 auto-signing · $0.01/call │
│ rolling 7-day window │ └───────────────┬────────────────────┘
│ trade history (≤500) │ │ USDC on Base
└──────────────────────────┘ ┌───────────────▼────────────────────┐
│ PinionOS Skill Server │
│ /price → Birdeye │
│ /trade → 1inch │
│ /chat → Anthropic │
└───────────────┬────────────────────┘
│ settlement
┌───────────────▼────────────────────┐
│ Base L2 Network │
│ swap broadcast by ethers.Wallet │
└────────────────────────────────────┘
TRIGGER
(cron / --run-now / dca_run_now)
│
▼
┌────────────────────────────┐
│ 1. Fetch price │──error──► log + record ❌ + exit cycle
│ skills.price("ETH") │
│ → priceUSD: 1932.45 │
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ 2. Budget gate │──no─────► skills.chat + record ⏭ + exit
│ spent+$10 ≤ $100? │
└────────────┬───────────────┘
│ yes
▼
┌────────────────────────────┐
│ 3. Cost basis guard ★ NEW │──skip───► skills.chat + record ⏭ + exit
│ currentPrice < │ "ETH is $1,960, 1.7% above avg of
│ avgCostBasis? │ $1,927. Waiting for a dip."
│ (first trade: always ✅) │
└────────────┬───────────────┘
│ yes (price below avg, or no prior trades)
▼
┌────────────────────────────┐
│ 4. Price-drop filter │──no─────► skills.chat + record ⏭ + exit
│ delta ≤ -N%? │
│ (skip if threshold=0) │
└────────────┬───────────────┘
│ yes (or disabled)
▼
┌────────────────────────────┐
│ 5. Build swap │──error──► skills.chat + record ❌ + exit
│ skills.trade( │
│ "USDC","ETH","10") │
│ → swap + approve? txs │
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ 6a. Approve (if needed) │──error──► skills.chat + record ❌ + exit
│ nonce N │ (spend NOT recorded)
│ await confirmation │
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ 6b. Swap │──error──► skills.chat + record ❌ + exit
│ nonce N+1 │ (spend NOT recorded)
│ await receipt.status=1 │
└────────────┬───────────────┘
│ confirmed on-chain
▼
┌────────────────────────────┐
│ 7. Update cost basis ★ NEW │
│ recordSpend($10) │
│ updateCostBasis( │
│ $10, $1,890) │
│ newAvg = $1,921 │
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ 8. Explain │
│ skills.chat( │
│ "…new avg $1,921") │
│ → "Bought $10 ETH…" │
└────────────┬───────────────┘
│
▼
┌────────────────────────────┐
│ 9. Persist │
│ appendTrade(record) │
│ → dca-state.json saved │
└────────────────────────────┘
status: "success" ✅
basescan link printed
pinion-dca-bot/
│
├── src/
│ ├── config.ts Typed env parsing — all settings, safe defaults
│ ├── spend-tracker.ts Persistent rolling 7-day budget + trade history
│ ├── agent.ts Core DCA logic — all Pinion skill orchestration
│ ├── scheduler.ts node-cron wrapper + EventEmitter interface
│ ├── mcp-server.ts MCP server — 5 Claude tools over stdio
│ └── index.ts Standalone entry point with --run-now flag
│
├── dist/ Compiled output (created by npm run build)
│ ├── agent.js
│ ├── config.js
│ ├── index.js ← node dist/index.js (standalone)
│ ├── mcp-server.js ← node dist/mcp-server.js (Claude MCP)
│ ├── scheduler.js
│ └── spend-tracker.js
│
├── dca-state.json Auto-created at runtime — trade log + budget window
├── .env.example All environment variables documented
├── claude-config.json Drop-in Claude Desktop MCP config block
├── package.json
└── tsconfig.json
All settings are read from environment variables with safe defaults. The only
required variable is PINION_PRIVATE_KEY. loadConfig() throws immediately with
a clear error if it is missing, rather than failing mysteriously later in the
agent.
export function loadConfig(): DCAConfig {
return {
privateKey: requireEnv("PINION_PRIVATE_KEY"), // hard required
targetToken: optionalEnv("DCA_TARGET_TOKEN", "ETH"),
amountPerTrade: optionalEnv("DCA_AMOUNT_PER_TRADE", "10"),
weeklyBudget: parseFloat(optionalEnv("DCA_WEEKLY_BUDGET", "100")),
cronSchedule: optionalEnv("DCA_CRON_SCHEDULE", "0 12 * * *"),
priceDropThresholdPct: parseFloat(optionalEnv("DCA_PRICE_DROP_THRESHOLD_PCT", "0")),
stateFile: optionalEnv("DCA_STATE_FILE", "./dca-state.json"),
};
}SpendTracker is the bot's financial memory. It persists to dca-state.json
so both the weekly budget and the all-time cost basis survive process restarts,
reboots, and MCP reconnections.
It now manages two independent concerns:
1. Rolling 7-day spend window (resets every 7 days):
Feb 22 ─── Window opens. totalSpent = $0 ────────────────────────┐
│ │
Feb 23 Trade ✅ totalSpent = $10 │
Feb 24 Trade ✅ totalSpent = $20 7
Feb 25 Trade ✅ totalSpent = $30 d
Feb 26 Trade ✅ totalSpent = $40 a
Feb 27 Trade ✅ totalSpent = $50 y
Feb 28 Trade ✅ totalSpent = $60 s
Mar 01 Trade ✅ totalSpent = $70 │
Mar 02 Trade ✅ totalSpent = $80 │
Mar 03 Trade ✅ totalSpent = $90 │
Mar 04 Trade ✅ totalSpent = $100 ← budget hit │
Mar 05 Skipped ⏭ ($0 remaining) │
... │
Mar 08 ─── Window auto-resets. totalSpent = $0 ─────────────────┘
2. All-time cost basis (never resets — accumulates forever):
// After each confirmed trade:
costBasis.totalUsdcSpent += amountSpent; // e.g. += $10
costBasis.totalTokensEstimated += amountSpent / price; // e.g. += 0.005291 ETH
costBasis.tradeCount += 1;
// Derived on every read:
avgCostBasis = totalUsdcSpent / totalTokensEstimated; // e.g. $1,921.00recordSpend() and updateCostBasis() are only called after on-chain
confirmation — failed broadcasts never affect the budget or the average.
Automatic backfill — if you load an existing dca-state.json that predates
the cost basis feature, rebuildCostBasisFromHistory() automatically recomputes
the correct average from your existing trade records on first load. No manual
migration needed.
State file shape (dca-state.json):
{
"spend": {
"windowStart": "2026-02-22T12:00:00.000Z",
"totalSpent": 40
},
"costBasis": {
"totalUsdcSpent": 40,
"totalTokensEstimated": 0.020891,
"tradeCount": 4
},
"trades": [
{
"id": "a1b2c3d4",
"timestamp": "2026-02-28T12:00:00.000Z",
"fromToken": "USDC",
"toToken": "ETH",
"amountSpent": 10,
"priceAtTrade": 1890.10,
"priceReference": 1932.45,
"priceDeltaPct": -2.19,
"avgCostBasisAtDecision": 1927.33,
"txData": {
"txHash": "0xabc...",
"to": "0x...",
"data": "0x..."
},
"explanation": "Bought $10 ETH at $1,890 — 1.9% below avg cost of $1,927. New avg: $1,921.",
"status": "success"
}
]
}Note the new fields: costBasis block at the top, and avgCostBasisAtDecision
in each trade record — showing exactly what the bot's average was at the moment
it made each decision, making the history fully auditable.
The agent is the heart of the system. runCycle() is a single atomic async
function that calls all three Pinion skills, broadcasts the tx, and persists the
result. Key implementation decisions:
Price field resilience — The live API returns data.priceUSD, not data.usd
as the documentation suggests. The parser walks 7 candidate paths before failing,
making the agent robust to future API shape changes:
const raw: unknown =
data?.priceUSD ?? // actual live API: { data: { priceUSD: 1932 } }
data?.usd ?? // documented shape
data?.price ??
data?.value ??
data?.result ??
d?.priceUSD ??
d?.price ??
priceResp; // last resort: response might itself be the numberExplicit nonce management — On the very first trade (or any time the mempool
is fresh), ethers would auto-assign nonce 0 to both the approve and swap tx,
causing the second to fail with nonce too low. The fix: fetch the pending nonce
once, pass it explicitly, and increment manually before sending the swap:
// Fetch pending nonce once — includes any unconfirmed txs in mempool
let nonce = await this.provider.getTransactionCount(wallet.address, "pending");
// Approve: nonce N
await wallet.sendTransaction({ ...approveTx, nonce });
nonce += 1; // pre-increment before awaiting confirmation
await approveSent.wait(1); // wait for confirm
// Swap: nonce N+1 — guaranteed sequential
await wallet.sendTransaction({ ...swapTx, nonce });Average cost basis guard (step 3) — Before building any swap, the agent compares the current price against the all-time average cost basis. If the price is at or above the average, buying would raise the average — which defeats the purpose of DCA. So the cycle is skipped with a clear explanation:
// Snapshot the avg before any state changes this cycle
const avgCostBasisAtDecision = this.tracker.avgCostBasis;
// null means no prior successful trades → always trade (first buy)
if (avgCostBasisAtDecision !== null && currentPrice >= avgCostBasisAtDecision) {
// skip — price is too high, explain why, record as "skipped"
}Cost basis updated before the explanation is generated — updateCostBasis()
is called before skills.chat(), so the explanation prompt contains the
accurate new average. Claude explains "my average is now $X" using the real
post-trade figure, not the stale pre-trade one.
Status only flips to success after on-chain confirmation — receipt.wait(1)
blocks until mined. A reverted tx is recorded as error with the on-chain revert
reason. The spend is only recorded after receipt.status === 1.
console.error throughout, never console.log — MCP communicates over
stdout using newline-delimited JSON-RPC. Any console.log() writes plain text
to stdout and Claude tries to parse it as a protocol message, producing the
Unexpected token 'D', "[DCA] Raw p"... JSON errors seen during development.
All logging uses console.error() which writes to stderr — invisible to the
MCP protocol channel, still visible in your terminal.
DCAScheduler wraps node-cron with an EventEmitter interface, keeping the
cron concern separated from both the agent logic and the output layer:
// Both the standalone index.ts and the MCP server listen to the same events
scheduler.on("cycle", (result) => { /* render output */ });
scheduler.on("error", (err) => { /* handle failure */ });
scheduler.on("started", (sched) => { /* confirm boot */ });
scheduler.on("stopped", () => { /* confirm halt */ });runNow() triggers a cycle immediately outside the cron schedule and emits the
same cycle event — used by both the --run-now CLI flag and the dca_run_now
MCP tool, ensuring identical behaviour in both modes.
The MCP server boots the full agent + scheduler on startup, then exposes 5 tools
over stdio using @modelcontextprotocol/sdk. All tool inputs are validated with
Zod schemas before reaching the agent:
const SetConfigArgs = z.object({
amountPerTrade: z.string().optional(),
weeklyBudget: z.number().positive().optional(),
cronSchedule: z.string().optional(),
priceDropThresholdPct: z.number().min(0).optional(),
});Malformed Claude tool calls fail with a clear validation error rather than
crashing the server. The scheduler is automatically restarted when cronSchedule
is changed via dca_set_config.
┌───────────────┬───────────────────────────────────────────────┐
│ Requirement │ Details │
├───────────────┼───────────────────────────────────────────────┤
│ Node.js ≥ 18 │ Check: node --version │
│ npm │ Bundled with Node │
│ Base wallet │ Needs ETH (gas) + USDC (trades + fees) │
│ Private key │ 0x hex key for the wallet above │
└───────────────┴───────────────────────────────────────────────┘
Minimum wallet balance for testing:
- ~0.001 ETH on Base for gas (Base is cheap — roughly $0.01 per tx)
- $1 USDC minimum for a test trade (plus $0.03 in Pinion skill fees)
Get USDC on Base:
- Bridge from Ethereum mainnet: bridge.base.org
- Or buy directly on Base via Coinbase, Uniswap, or any DEX supporting Base
git clone <your-repo-url>
cd pinion-dca-bot
npm installcp .env.example .envEdit .env:
# ── Required ──────────────────────────────────────────────────────────
PINION_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE
# ── Optional (shown with defaults) ───────────────────────────────────
DCA_TARGET_TOKEN=ETH
DCA_AMOUNT_PER_TRADE=10
DCA_WEEKLY_BUDGET=100
DCA_CRON_SCHEDULE=0 12 * * *
DCA_PRICE_DROP_THRESHOLD_PCT=0
DCA_STATE_FILE=./dca-state.jsonnpm run buildA dist/ folder will be created containing the compiled JavaScript.
Build succeeds but no
dist/folder? Yourtsconfig.jsonhas"noEmit": true— remove it. Also remove"allowImportingTsExtensions": true(it forces noEmit). See thetsconfig.jsonin this repo for the correct settings.
# After building:
node dist/index.js
# Or directly with ts-node during development:
npm run devThe bot starts, prints its status report, then waits for the next cron tick.
Expected startup output:
╔══════════════════════════════════════╗
║ 🤖 Pinion DCA Bot | @PinionOS ║
╚══════════════════════════════════════╝
Target token : ETH
Per trade : $10 USDC
Weekly budget : $100 USDC
Schedule : 0 12 * * *
Price filter : disabled (trade on every cycle)
═══════════════════════════════════
🤖 Pinion DCA Bot — Status
═══════════════════════════════════
Weekly budget: $100.00 USDC
Spent this week (since Feb 28): $20.00 USDC
Remaining: $80.00 USDC
Target: ETH | Amount per trade: $10 USDC
Schedule: 0 12 * * *
── Recent Trades ──────────────────
✅ [Feb 28, 02:50 AM] Bought $10 ETH at $1,890 — 1.9% below avg $1,927. New avg $1,921.
⏭ [Feb 28, 02:48 AM] ETH at $1,932 is above avg $1,927 — skipped to protect average.
✅ [Feb 28, 02:47 AM] Bought $10 ETH at $1,929 — first trade this session.
═══════════════════════════════════
[Scheduler] ✅ Started — schedule: "0 12 * * *"
[Scheduler] Next trade: Sun, 01 Mar 2026 12:00:00 GMT
Press Ctrl+C to shut down gracefully.
node dist/mcp-server.jsOr configure Claude Desktop to launch it automatically — see Section 10.
By default the bot only trades at the scheduled cron time. Use the --run-now
flag to trigger a complete trade cycle immediately at any time, without
waiting for the next tick. This is the primary way to test.
node dist/index.js --run-now
# With ts-node:
npm run dev -- --run-nowDCA_RUN_NOW=true node dist/index.js▶️ --run-now detected — triggering immediate trade cycle…
[DCA] Sending swap tx (nonce 42)…
[DCA] Swap tx sent: 0xabc123… — waiting for confirmation…
[DCA] Swap confirmed on Base ✅ block 28473920
[DCA] ✅ Trade a1b2c3d4 confirmed — Bought $10 ETH at $1,932, price was
3.1% below my reference of $1,994. $30 of $100 weekly budget used.
[DCA] 🔗 https://basescan.org/tx/0xabc123…
✅ [2026-02-28T02:50:14.000Z]
Bought $10 ETH at $1,932, a good DCA entry.
📋 Swap tx: { "txHash": "0xabc123...", "to": "0x...", ... }
═══════════════════════════════════
🤖 Pinion DCA Bot — Status
═══════════════════════════════════
Weekly budget: $100.00 USDC
Spent this week (since Feb 28): $30.00 USDC
Remaining: $70.00 USDC
...
✅ [Feb 28, 02:50 AM] Bought $10 ETH at $1,932 — 3.1% below reference.
═══════════════════════════════════
💡 Tip: Remove --run-now (or DCA_RUN_NOW=true) to switch to scheduled mode.
The process exits after the single run. To run once and then keep the
scheduler alive for subsequent cron ticks, remove the process.exit(0) line
at the bottom of the if (runNow) block in src/index.ts.
DCA_AMOUNT_PER_TRADE=1 node dist/index.js --run-nowThis trades $1 USDC for ETH — total cost $1.03 (the $1 trade plus $0.03 in Pinion skill fees).
The MCP server exposes 5 tools to Claude Desktop, Claude Code, or any MCP host. The scheduler starts automatically when the MCP server boots, so autonomous trading happens in the background while Claude stays interactive.
npm run build Windows → %APPDATA%\Claude\claude_desktop_config.json
macOS → ~/Library/Application Support/Claude/claude_desktop_config.json
Linux → ~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"pinion-dca": {
"command": "node",
"args": ["C:\\Users\\YOU\\path\\to\\pinion-dca-bot\\dist\\mcp-server.js"],
"env": {
"PINION_PRIVATE_KEY": "0xYOUR_KEY_HERE",
"DCA_TARGET_TOKEN": "ETH",
"DCA_AMOUNT_PER_TRADE": "10",
"DCA_WEEKLY_BUDGET": "100",
"DCA_CRON_SCHEDULE": "0 12 * * *",
"DCA_PRICE_DROP_THRESHOLD_PCT": "0"
}
}
}
}Use
"command": "node"pointing to the compiled local file. Do NOT use"command": "npx"with"args": ["pinion-dca-bot"]— that tries to download a package from npm that does not exist there, producingnpm error 404 Not Found.
System tray icon → Quit, then reopen. A simple window close is not enough.
┌──────────────────┬──────────────────────────────────────────────────┐
│ Tool │ What it does │
├──────────────────┼──────────────────────────────────────────────────┤
│ dca_status │ Show budget, remaining allowance, schedule, and │
│ │ last 5 trades with plain-English explanations │
├──────────────────┼──────────────────────────────────────────────────┤
│ dca_run_now │ Trigger one full cycle right now — fetches price,│
│ │ checks budget, builds + broadcasts swap on Base, │
│ │ returns confirmed tx hash and explanation │
├──────────────────┼──────────────────────────────────────────────────┤
│ dca_history │ Return N most recent trade records as JSON │
│ │ (default 10, max 50) │
├──────────────────┼──────────────────────────────────────────────────┤
│ dca_set_config │ Update amountPerTrade, weeklyBudget, │
│ │ cronSchedule, or priceDropThresholdPct at │
│ │ runtime — no restart needed │
├──────────────────┼──────────────────────────────────────────────────┤
│ dca_start_stop │ Start or stop the autonomous cron scheduler │
└──────────────────┴──────────────────────────────────────────────────┘
"What's my DCA bot status?"
→ calls dca_status
"Run a trade now"
→ calls dca_run_now
"Show me the last 20 trades"
→ calls dca_history { limit: 20 }
"Change my per-trade amount to $25"
→ calls dca_set_config { amountPerTrade: "25" }
"Set my weekly budget to $200"
→ calls dca_set_config { weeklyBudget: 200 }
"Only buy ETH when it drops 5% or more from reference"
→ calls dca_set_config { priceDropThresholdPct: 5 }
"Trade every 6 hours instead of daily"
→ calls dca_set_config { cronSchedule: "0 */6 * * *" }
"Pause the bot for now"
→ calls dca_start_stop { action: "stop" }
┌──────────────────────────────┬──────────────────┬──────────────────────────────────────┐
│ Variable │ Default │ Description │
├──────────────────────────────┼──────────────────┼──────────────────────────────────────┤
│ PINION_PRIVATE_KEY │ required │ Hex private key (0x...) on Base │
│ DCA_TARGET_TOKEN │ ETH │ Token to accumulate │
│ DCA_SOURCE_TOKEN │ USDC │ Token to spend │
│ DCA_AMOUNT_PER_TRADE │ 10 │ USDC per trade cycle │
│ DCA_WEEKLY_BUDGET │ 100 │ Max USDC per rolling 7-day window │
│ DCA_CRON_SCHEDULE │ 0 12 * * * │ When to trade (cron expression) │
│ DCA_PRICE_DROP_THRESHOLD_PCT │ 0 │ Min % drop needed (0 = always trade) │
│ DCA_STATE_FILE │ ./dca-state.json │ Persistent state location │
│ PINION_API_URL │ pinionos.com/... │ Override skill API endpoint │
│ PINION_NETWORK │ base │ base or base-sepolia │
└──────────────────────────────┴──────────────────┴──────────────────────────────────────┘
0 12 * * * Every day at noon UTC (default — daily DCA)
0 */12 * * * Every 12 hours (twice-daily DCA)
0 9,17 * * * 9am and 5pm UTC daily (bi-daily with timing)
0 12 * * 1 Every Monday noon UTC (weekly DCA)
0 12 1 * * 1st of every month (monthly DCA)
*/30 * * * * Every 30 minutes (aggressive DCA)
The cost basis guard is the core DCA logic. Every trade must lower the average entry price — otherwise you are not dollar-cost averaging, you are just buying on a timer regardless of whether you're getting a good price.
avgCostBasis = totalUsdcSpentAllTime / totalTokensEstimatedAllTime
totalTokensEstimated is an approximation:
each trade contributes amountSpent / priceAtTrade tokens
Example after 4 trades:
Trade 1: $10 at $2,000 → 0.005000 ETH
Trade 2: $10 at $1,900 → 0.005263 ETH
Trade 3: $10 at $1,850 → 0.005405 ETH
Trade 4: $10 at $1,800 → 0.005556 ETH
─────────────────────────────────────────
Total: $40 spent 0.021224 ETH accumulated
avgCostBasis = $40 / 0.021224 = $1,885.00
On each cycle:
currentPrice vs avgCostBasis
─────────────────────────────────────────────────────────────
$1,800 < $1,885 (avg) → ✅ TRADE — lowers avg to ~$1,872
$1,885 = $1,885 (avg) → ⏭ SKIP — would not improve avg
$1,950 > $1,885 (avg) → ⏭ SKIP — would raise avg (bad)
null avg (first trade ever) → ✅ TRADE — always execute
Without the guard With the guard
─────────────────────────── ───────────────────────────
Buy $10 at $2,000 Buy $10 at $2,000
Buy $10 at $2,100 (worse) SKIP at $2,100 (above avg)
Buy $10 at $1,900 Buy $10 at $1,900 (below avg)
Buy $10 at $2,050 (worse) SKIP at $2,050 (above avg)
Buy $10 at $1,800 Buy $10 at $1,800 (below avg)
avg after 5 trades: $1,970 avg after 3 trades: $1,900
─ bought at peaks ─ only bought dips
Price Action Avg After Trade
───────────────────────────────────────
$2,000 BUY ✅ $2,000 (first trade)
$2,100 SKIP ⏭ $2,000 (unchanged)
$1,950 SKIP ⏭ $2,000 (unchanged — $1,950 < $2,000 but > avg)
$1,900 BUY ✅ $1,947 ↓
$1,950 SKIP ⏭ $1,947 (unchanged)
$1,880 BUY ✅ $1,924 ↓
$1,920 SKIP ⏭ $1,924 (unchanged)
$1,860 BUY ✅ $1,908 ↓
$1,840 BUY ✅ $1,892 ↓ ← avg keeps falling
Every trade record (success or skip) now stores avgCostBasisAtDecision — the
average at the moment the bot made its decision. This lets you audit the full
history and verify every buy decision was rational.
The dca_status output includes the current average:
═══════════════════════════════════
🤖 Pinion DCA Bot — Status
═══════════════════════════════════
Weekly budget: $100.00 USDC
Spent this week (since Feb 28): $30.00 USDC
Remaining: $70.00 USDC
Avg cost basis (ETH): $1,921.00 | Total invested: $40.00 | Est. holdings: 0.020891 ETH
...
The strategy is intentionally simple and easy to reason about. Natural extensions to build on top of it:
- Boost size on bigger dips — trade $20 instead of $10 when price is >5% below avg
- Volatility-adjusted threshold — widen the gap required during low-volatility periods
- Moving average as reference — use a 30-day MA instead of all-time avg to be more responsive
- Profit-taking trigger — optionally sell a portion when price exceeds avg by N%
Every possible outcome is handled and recorded:
TRIGGER
│
├──[price fetch fails]────────────► ❌ error
│ RPC down, Pinion unavailable explanation via skills.chat
│ spend NOT recorded
│ cost basis NOT updated
│
├──[budget exhausted]─────────────► ⏭ skipped
│ weeklySpent + amount > budget explanation via skills.chat
│ spend NOT recorded
│ cost basis NOT updated
│
├──[price ≥ avg cost basis] ★ NEW─► ⏭ skipped
│ buying would raise the average explanation via skills.chat
│ (skipped if no prior trades) "ETH $1,950 > avg $1,927"
│ cost basis NOT updated
│
├──[price drop threshold not met]─► ⏭ skipped
│ priceDelta > -threshold% explanation via skills.chat
│ (only if threshold > 0) cost basis NOT updated
│
├──[skills.trade fails]───────────► ❌ error
│ 1inch route unavailable explanation via skills.chat
│ cost basis NOT updated
│
├──[broadcast fails / reverts]────► ❌ error
│ gas too low, slippage, RPC explanation via skills.chat
│ spend NOT recorded (no funds moved)
│ cost basis NOT updated
│
└──[all steps succeed]────────────► ✅ success
tx mined on Base recordSpend($10)
basescan link printed updateCostBasis($10, price) ★ NEW
explanation includes new avg ★ NEW
trade appended to history
Budget: $100/week | Per trade: $10 | Schedule: daily
Week window (rolling 7 days — not calendar week):
Day 1 ████░░░░░░░░░░░░░░░░ $10 / $100
Day 2 ████████░░░░░░░░░░░░ $20 / $100
Day 3 ████████████░░░░░░░░ $30 / $100
Day 4 ████████████████░░░░ $40 / $100
Day 5 ████████████████████ $50 / $100
Day 6 ████████████████████ $60 / $100
Day 7 ████████████████████ $70 / $100
Day 8 ████████████████████ $80 / $100
Day 9 ████████████████████ $90 / $100
Day 10 ████████████████████ $100 / $100 ← budget hit
Day 11 ████████████████████ Skipped ⏭
Day 12 ████████████████████ Skipped ⏭
...
Day 15 Window resets $0 / $100
Day 15 ████░░░░░░░░░░░░░░░░ $10 / $100 ← trading resumes
The weekly spend tracker only counts confirmed, successful trades toward the budget. Failed broadcasts, skipped cycles, and price-fetch errors do not deduct anything.
The cost basis tracker follows the same rule — updateCostBasis() is only called
after receipt.status === 1. A skipped cycle (even one skipped by the cost basis
guard) never touches the average.
Your tsconfig.json has "noEmit": true. That flag tells TypeScript to
type-check only and write nothing to disk. Remove it. Also remove
"allowImportingTsExtensions": true (it forces noEmit and cannot coexist with
output). The correct tsconfig.json in this repo has neither flag.
A console.log() is writing plain text to stdout, which is the MCP JSON-RPC
channel. Claude tries to parse every stdout line as a protocol message and fails.
All logging in this project uses console.error() (stderr only). If you add any
debug output, always use console.error, never console.log.
This is the nonce collision between approve and swap transactions. Both were
getting auto-assigned nonce 0 by ethers when the mempool was fresh. The fix in
this codebase is to call getTransactionCount(address, "pending") once and pass
the nonce explicitly to both transactions, incrementing by 1 between them.
Your claude_desktop_config.json is using "command": "npx" with
"args": ["pinion-dca-bot"]. That tells npm to download a package from the
registry which does not exist there. Change "command" to "node" and set
"args" to the full local path of dist/mcp-server.js:
"command": "node",
"args": ["C:\\Users\\you\\pinion-dca-bot\\dist\\mcp-server.js"]The variable is not reaching the process. In Claude Desktop MCP config, add it
to the env block. In standalone mode, ensure your .env file is present in
the project root or export the variable in your shell before running.
This was a previous bug (now fixed) where status: "success" was recorded
immediately after skills.trade() returned the unsigned calldata, without ever
broadcasting it. The current code calls wallet.sendTransaction() followed by
receipt.wait(1) and only records success after receipt.status === 1.
Your wallet needs USDC on Base mainnet — not Ethereum mainnet, not Polygon. Bridge at bridge.base.org or buy directly on Base. Also ensure the wallet has a small amount of ETH on Base for gas (~0.001 ETH).
One primitive = everything your agent needs to operate.