Skip to content

anoop04singh/pinion-dca-bot

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🤖 Pinion DCA Bot

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.


Table of Contents

  1. What It Does
  2. How PinionOS Powers It
  3. Architecture
  4. Project Structure
  5. Implementation Deep-Dive
  6. Prerequisites
  7. Setup and Installation
  8. Running the Bot
  9. Testing at the Current Time with --run-now
  10. Claude MCP Integration
  11. Configuration Reference
  12. Cost Basis Strategy
  13. Trade Lifecycle
  14. Budget Tracking
  15. Troubleshooting

1. What It Does

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.

2. How PinionOS Powers It

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             │
  └─────────────────────────────────────────────────────────┘

x402 Payment Flow (handled automatically by the SDK)

  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.

3. Architecture

System Overview

┌──────────────────────────────────────────────────────────────────┐
│              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   │
                              └────────────────────────────────────┘

Single Trade Cycle Data Flow

  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

4. Project Structure

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

5. Implementation Deep-Dive

config.ts — Environment-First Configuration

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"),
  };
}

spend-tracker.ts — Budget Conscience + Cost Basis Memory

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.00

recordSpend() 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.

agent.ts — The Core DCA Brain

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 number

Explicit 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 generatedupdateCostBasis() 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 confirmationreceipt.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.

scheduler.ts — EventEmitter Cron Wrapper

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.

mcp-server.ts — Claude Integration

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.


6. Prerequisites

  ┌───────────────┬───────────────────────────────────────────────┐
  │ 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

7. Setup and Installation

Step 1 — Install dependencies

git clone <your-repo-url>
cd pinion-dca-bot
npm install

Step 2 — Create your .env file

cp .env.example .env

Edit .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.json

Step 3 — Build

npm run build

A dist/ folder will be created containing the compiled JavaScript.

Build succeeds but no dist/ folder? Your tsconfig.json has "noEmit": true — remove it. Also remove "allowImportingTsExtensions": true (it forces noEmit). See the tsconfig.json in this repo for the correct settings.


8. Running the Bot

Standalone mode (no Claude required)

# After building:
node dist/index.js

# Or directly with ts-node during development:
npm run dev

The 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.

As Claude MCP server

node dist/mcp-server.js

Or configure Claude Desktop to launch it automatically — see Section 10.


9. Testing at the Current Time — --run-now

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.

Method 1 — CLI flag (recommended)

node dist/index.js --run-now

# With ts-node:
npm run dev -- --run-now

Method 2 — Environment variable

DCA_RUN_NOW=true node dist/index.js

What to expect

▶️  --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.

Test with a small amount to minimise cost

DCA_AMOUNT_PER_TRADE=1 node dist/index.js --run-now

This trades $1 USDC for ETH — total cost $1.03 (the $1 trade plus $0.03 in Pinion skill fees).


10. Claude MCP Integration

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.

Step 1 — Build

npm run build

Step 2 — Find your Claude Desktop config file

  Windows  →  %APPDATA%\Claude\claude_desktop_config.json
  macOS    →  ~/Library/Application Support/Claude/claude_desktop_config.json
  Linux    →  ~/.config/Claude/claude_desktop_config.json

Step 3 — Add the MCP server block

{
  "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, producing npm error 404 Not Found.

Step 4 — Restart Claude Desktop fully

System tray icon → Quit, then reopen. A simple window close is not enough.

Available MCP Tools

  ┌──────────────────┬──────────────────────────────────────────────────┐
  │ 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      │
  └──────────────────┴──────────────────────────────────────────────────┘

Example Claude prompts

"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" }

11. Configuration Reference

  ┌──────────────────────────────┬──────────────────┬──────────────────────────────────────┐
  │ 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                 │
  └──────────────────────────────┴──────────────────┴──────────────────────────────────────┘

Cron schedule examples

  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)

12. Cost Basis Strategy

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.

The Math

  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

Decision Logic

  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

Why This Works

  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

Average Over Time (example trajectory)

  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

What Gets Recorded

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
...

Future Improvements

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%

13. Trade Lifecycle

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

14. Budget Tracking

  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.


15. Troubleshooting

npm run build succeeds but no dist/ appears

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.

MCP warnings: Unexpected token 'D', "[DCA] Raw p"... is not valid JSON

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.

First trade fails with nonce error, retry works

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.

npm error 404 Not Found — pinion-dca-bot

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"]

PINION_PRIVATE_KEY environment variable is required

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.

Trades log as ✅ but nothing happens on-chain

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.

insufficient USDC balance

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).


Built for the PinionOS Hackathon 🏆

One primitive = everything your agent needs to operate.

@PinionOS · pinionos.com

About

A fully autonomous agent that dollar-cost-averages into ETH (or any token) on a configurable schedule.

Topics

Resources

Stars

Watchers

Forks

Contributors