Skill: in-liberty420/solana-easy-swap (version 1.0.1)

--- name: solana-easy-swap description: Swap any Solana token from chat. Say 'swap 1 SOL for USDC' and it handles everything — quoting, signing, sending, confirming. No API keys, no wallet extensions, no setup beyond a keypair. Powered by Jupiter. Use when a user wants to swap, trade, exchange, buy, or sell Solana SPL tokens, SOL, USDC, memecoins, or any token pair on Solana. metadata: { "openclaw": { "emoji": "🔄", "requires": { "bins": ["node"], "env": ["SOLANA_KEYPAIR_PATH"] }, "install": [{ "id": "npm", "kind": "command", "command": "cd {baseDir} && npm install --production", "label": "Install dependencies" }] } } --- # Solana Easy Swap Swap any Solana token from chat. Say "swap 1 SOL for USDC" and it handles everything — quoting, signing, sending, confirming. No API keys, no wallet extensions, no setup beyond a keypair. Powered by Jupiter. ## Setup **First run:** Install dependencies (automatic if install spec is supported, otherwise manual): ```bash cd {baseDir} && npm install --production ``` Required env var: - `SOLANA_KEYPAIR_PATH` — path to a Solana keypair JSON file (standard `solana-keygen` format). **This skill reads your keypair to sign transactions.** Only use with a keypair you trust this skill to access. Optional env vars: - `SOLANA_RPC_URL` — custom RPC endpoint (default: `https://api.mainnet-beta.solana.com`) - `OSS_DEFAULT_SLIPPAGE_BPS` — default slippage in basis points (default: `100` = 1%) - `OSS_PRIORITY_FEE_FLOOR` — minimum priority fee in lamports (default: `50000`) No API keys required. Jupiter is used unauthenticated. ## Common Token Mints | Token | Mint | |---|---| | SOL (wrapped) | [REDACTED_SECRET] | | USDC | [REDACTED_SECRET] | | USDT | [REDACTED_SECRET] | For other tokens, ask the user for the mint address. ## Flow ### 1. Prepare ```bash node {baseDir}/scripts/swap.mjs prepare \ --from So11111111111111111111111111111111111111112 \ --to EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v \ --amount 1000000000 \ --slippage 100 ``` Returns JSON: ```json { "prepareId": "abc123", "expectedOut": "150230000", "minOut": "148727700", "priceImpact": "0.01", "expiresAt": "2025-02-13T20:00:00Z", "summary": { "from": "1 SOL", "to": "~150.23 USDC", "minReceived": "148.73 USDC", "slippage": "1%", "priceImpact": "0.01%", "destination": "owner" } } ``` **Always show the summary to the user and wait for confirmation before executing.** If `priceImpact` > 1%, warn the user explicitly. ### 2. Execute After user confirms: ```bash node {baseDir}/scripts/swap.mjs execute --prepareId abc123 ``` Returns JSON: ```json { "signature": "5UzV...", "submittedAt": "2025-02-13T19:58:12Z" } ``` ### 3. Status (poll until confirmed) ```bash node {baseDir}/scripts/swap.mjs status --signature 5UzV... ``` Returns JSON: ```json { "state": "confirmed", "slot": 123456789, "confirmationStatus": "finalized" } ``` States: `submitted` → `confirmed` | `failed` | `expired` | `unknown` ### 4. Receipt ```bash node {baseDir}/scripts/swap.mjs receipt --signature 5UzV... ``` Returns JSON with actual amounts swapped, fees, and a Solscan link. ## Error Handling All commands return JSON with `error` field on failure: ```json { "error": { "code": "INSUFFICIENT_SOL", "message": "Not enough SOL for fees. Have 0.001, need ~0.006", "retryable": false } } ``` Error codes and retry guidance: | Code | Retry? | Action | |---|---|---| | `INVALID_INPUT` | No | Fix the input | | `INSUFFICIENT_SOL` | No | Tell user they need more SOL | | `KEYPAIR_NOT_FOUND` | No | Check `SOLANA_KEYPAIR_PATH` is set | | `KEYPAIR_INVALID` | No | Check keypair file format | | `PREPARE_EXPIRED` | Yes | Run `prepare` again, re-confirm with user | | `PREPARE_ALREADY_EXECUTED` | No | This swap was already sent | | `BACKEND_UNAVAILABLE` | Yes | Wait 3s, retry prepare up to 2x | | `BACKEND_QUOTE_FAILED` | No | No route — tell user (bad pair or no liquidity) | | `TX_EXPIRED` | Yes | Run `prepare` again, re-confirm with user | | `TX_BROADCAST_FAILED` | Yes | Retry execute once (if not expired) | | `TX_FAILED_ONCHAIN` | No | Swap failed (e.g., slippage). Do NOT retry. | | `RPC_UNAVAILABLE` | Yes | Wait 3s, retry up to 2x | ## Agent Guidelines 1. **Always confirm before executing.** Show the user the summary from `prepare` and wait for explicit "yes" / "go" / "confirm". 2. **Never auto-retry failed onchain transactions.** If `TX_FAILED_ONCHAIN`, the tx landed and failed — retrying sends a new tx. 3. **Re-confirm on re-prepare.** If you need to prepare again (expired quote), show the new summary — prices may have changed. 4. **Handle amounts in base units.** SOL = 9 decimals (1 SOL = 1000000000), USDC = 6 decimals (1 USDC = 1000000). 5. **Ask for mint addresses** if the user mentions a token you don't recognize. Don't guess. 6. **Report the Solscan link** after confirmation: `https://solscan.io/tx/{signature}` ## Security - This skill signs transactions using the configured keypair. It does NOT create, import, or manage keys. - Keypair material is never logged, echoed, or included in any output. - Third-party destinations require explicit `--allowThirdParty` flag. - All swaps enforce slippage protection via `minOut`. - Prepared swaps expire after 120 seconds by default. ## Limitations (v1) - Jupiter unauthenticated API — Token2022 and pump.fun tokens may not work. - No best-price routing — takes whatever Jupiter returns. - Receipt amounts are best-effort (derived from pre/post balance diffs). - Solana mainnet only.

path: examples/agent-prompt.md
content: # Solana Swap — Reference Agent Prompt

Use this as guidance for how an agent should handle swap requests when the `solana-swap` skill is installed.

## Handling a Swap Request

When a user asks to swap tokens (e.g., "swap 1 SOL for USDC", "trade 50 USDC to SOL", "buy some BONK with 0.5 SOL"):

### 1. Parse the Intent

Extract:
- **From token** — what they're selling
- **To token** — what they're buying
- **Amount** — how much they're selling

If any of these are unclear, ask. Don't guess tokens — if you don't recognize a token name, ask for the mint address.

Convert the amount to base units before calling prepare:
- SOL: × 1,000,000,000 (9 decimals)
- USDC/USDT: × 1,000,000 (6 decimals)
- BONK: × 100,000 (5 decimals)
- Other tokens: ask the user or look up decimals

### 2. Prepare the Swap

Call `prepare` with the parsed intent. If it succeeds, present the summary to the user clearly:

> **Swap Summary**
> Selling: 1 SOL
> Receiving: ~150.23 USDC (min 148.73 USDC)
> Slippage: 1%
> Price Impact: 0.01%
>
> Confirm?

**If price impact > 1%**, add a warning:
> ⚠️ Price impact is 3.2% — this is high. You'll get significantly less than market rate. Still want to proceed?

**If price impact > 5%**, strongly discourage:
> ⚠️ Price impact is 7.8% — this is very high. You'll lose a lot to slippage. Consider a smaller amount or a different pair. Proceed anyway?

Wait for explicit confirmation. Acceptable confirmations: "yes", "y", "go", "do it", "confirm", "proceed", "send it", etc.

If the user says no, cancel, or changes their mind — acknowledge and stop.

### 3. Execute the Swap

After confirmation, call `execute` with the `prepareId`.

If it returns a signature, tell the user:
> Swap submitted! Waiting for confirmation...

Then poll `status` every 2-3 seconds until the state is `confirmed`, `failed`, or `expired`.

### 4. Report the Result

On **confirmed**:
Call `receipt` and report:
> ✅ Swap complete!
> Sent: 1 SOL
> Received: 150.19 USDC
> Tx: https://solscan.io/tx/{signature}

On **failed**:
> ❌ Swap failed on-chain (likely slippage — price moved too much). No tokens were lost beyond the transaction fee.
> Want to try again with higher slippage?

On **expired** (status stays `submitted` for too long):
> ⏰ Transaction may have expired. Checking...
> If not confirmed after ~60s, suggest re-preparing.

## Error Handling

### Retryable Errors (agent can retry automatically)

| Error | Action |
|---|---|
| `BACKEND_UNAVAILABLE` | Wait 3s, retry `prepare` up to 2x. If still failing: "Jupiter is currently down. Try again in a few minutes." |
| `RPC_UNAVAILABLE` | Wait 3s, retry up to 2x. If still failing: "Solana RPC is unreachable. Try again shortly." |
| `TX_EXPIRED` | Re-run `prepare`, show new summary, re-confirm with user (prices may have changed). |
| `TX_BROADCAST_FAILED` | Retry `execute` once with same `prepareId` (if not expired). If still failing, re-prepare. |
| `PREPARE_EXPIRED` | Re-run `prepare`, show new summary, re-confirm with user. |

### Non-Retryable Errors (report to user)

| Error | Response |
|---|---|
| `INVALID_INPUT` | Report the specific issue. Ask user to fix. |
| `INSUFFICIENT_SOL` | "You don't have enough SOL. You have X, need ~Y for this swap + fees." |
| `KEYPAIR_NOT_FOUND` | "Wallet isn't configured. Set `SOLANA_KEYPAIR_PATH` to your keypair file." |
| `KEYPAIR_INVALID` | "Keypair file couldn't be read. Check it's a valid Solana keypair JSON." |
| `BACKEND_QUOTE_FAILED` | "Jupiter can't find a route for this swap. Might be no liquidity or an invalid token pair." |
| `TX_FAILED_ONCHAIN` | "Swap failed on-chain. **Do not retry automatically.** Ask user if they want to try again (maybe with higher slippage)." |
| `PREPARE_ALREADY_EXECUTED` | "This swap was already submitted. Check status instead." |

### Critical Rule: Never Auto-Retry `TX_FAILED_ONCHAIN`

This means the transaction landed on Solana and failed (e.g., slippage exceeded). The user paid a fee. Retrying sends a **new** transaction with a **new** fee. Always ask the user first.

## Edge Cases

### User says "swap all my SOL"
Don't swap literally all — they need SOL for the transaction fee. Subtract ~0.01 SOL from the amount as a buffer, and tell the user:
> Swapping 4.99 SOL (keeping ~0.01 for fees).

### User mentions a token you don't know
Ask for the mint address. Don't guess or search — wrong mint = wrong token = lost funds.

### User asks for a very small swap
Proceed normally. Jupiter may not find a route for dust amounts — if `BACKEND_QUOTE_FAILED`, explain that the amount might be too small.

### User asks about swap status later
If you have the signature from a previous swap, call `status` or `receipt`. If you don't have it, ask for the transaction signature.

### User wants to swap to a different wallet
This requires the `--allowThirdParty` flag in prepare. Confirm the destination address with the user explicitly:
> You want to send the USDC to `ABC123...`? This is a different wallet than yours. Confirm?

## What This Skill Does NOT Do

- **No best price routing** — takes Jupiter's default route
- **No limit orders** — executes immediately at market
- **No cross-chain** — Solana only
- **No wallet management** — doesn't create or import keys
- **No token discovery** — doesn't search for tokens by name
- **No portfolio tracking** — just swaps

If a user asks for any of these, let them know it's not supported and suggest alternatives if you know any.


path: package-lock.json
content: {
  "name": "openclaw-solana-swap",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "openclaw-solana-swap",
      "version": "1.0.0",
      "dependencies": {
        "@solana/web3.js": "^1.98.0",
        "bs58": "^6.0.0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@noble/curves": {
      "version": "1.9.7",
      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@noble/hashes": "1.8.0"
      },
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@noble/hashes": {
      "version": "1.8.0",
      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^14.21.3 || >=16"
      },
      "funding": {
        "url": "https://paulmillr.com/funding/"
      }
    },
    "node_modules/@solana/buffer-layout": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "buffer": "~6.0.3"
      },
      "engines": {
        "node": ">=5.10"
      }
    },
    "node_modules/@solana/codecs-core": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@solana/errors": "2.3.0"
      },
      "engines": {
        "node": ">=20.18.0"
      },
      "peerDependencies": {
        "typescript": ">=5.3.3"
      }
    },
    "node_modules/@solana/codecs-numbers": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@solana/codecs-core": "2.3.0",
        "@solana/errors": "2.3.0"
      },
      "engines": {
        "node": ">=20.18.0"
      },
      "peerDependencies": {
        "typescript": ">=5.3.3"
      }
    },
    "node_modules/@solana/errors": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "chalk": "^5.4.1",
        "commander": "^14.0.0"
      },
      "bin": {
        "errors": "bin/cli.mjs"
      },
      "engines": {
        "node": ">=20.18.0"
      },
      "peerDependencies": {
        "typescript": ">=5.3.3"
      }
    },
    "node_modules/@solana/web3.js": {
      "version": "1.98.4",
      "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@babel/runtime": "^7.25.0",
        "@noble/curves": "^1.4.2",
        "@noble/hashes": "^1.4.0",
        "@solana/buffer-layout": "^4.0.1",
        "@solana/codecs-numbers": "^2.1.0",
        "agentkeepalive": "^4.5.0",
        "bn.js": "^5.2.1",
        "borsh": "^0.7.0",
        "bs58": "^4.0.1",
        "buffer": "6.0.3",
        "fast-stable-stringify": "^1.0.0",
        "jayson": "^4.1.1",
        "node-fetch": "^2.7.0",
        "rpc-websockets": "^9.0.2",
        "superstruct": "^2.0.2"
      }
    },
    "node_modules/@solana/web3.js/node_modules/base-x": {
      "version": "3.0.11",
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "safe-buffer": "^5.0.1"
      }
    },
    "node_modules/@solana/web3.js/node_modules/bs58": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "base-x": "^3.0.2"
      }
    },
    "node_modules/@swc/helpers": {
      "version": "0.5.18",
      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.8.0"
      }
    },
    "node_modules/@types/connect": {
      "version": "3.4.38",
      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/node": "*"
      }
    },
    "node_modules/@types/node": {
      "version": "12.20.55",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/uuid": {
      "version": "8.3.4",
      "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/ws": {
      "version": "7.4.7",
      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/node": "*"
      }
    },
    "node_modules/agentkeepalive": {
      "version": "4.6.0",
      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "humanize-ms": "^1.2.1"
      },
      "engines": {
        "node": ">= 8.0.0"
      }
    },
    "node_modules/base-x": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/base64-js": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/bn.js": {
      "version": "5.2.2",
      "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/borsh": {
      "version": "0.7.0",
      "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "bn.js": "^5.2.0",
        "bs58": "^4.0.0",
        "text-encoding-utf-8": "^1.0.2"
      }
    },
    "node_modules/borsh/node_modules/base-x": {
      "version": "3.0.11",
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "safe-buffer": "^5.0.1"
      }
    },
    "node_modules/borsh/node_modules/bs58": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "base-x": "^3.0.2"
      }
    },
    "node_modules/bs58": {
      "version": "6.0.0",
      "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "base-x": "^5.0.0"
      }
    },
    "node_modules/buffer": {
      "version": "6.0.3",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "base64-js": "^1.3.1",
        "ieee754": "^1.2.1"
      }
    },
    "node_modules/bufferutil": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "node-gyp-build": "^4.3.0"
      },
      "engines": {
        "node": ">=6.14.2"
      }
    },
    "node_modules/chalk": {
      "version": "5.6.2",
      "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": "^12.17.0 || ^14.13 || >=16.0.0"
      },
      "funding": {
        "url": "https://github.com/chalk/chalk?sponsor=1"
      }
    },
    "node_modules/commander": {
      "version": "14.0.3",
      "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/delay": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/es6-promise": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/es6-promisify": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es6-promise": "^4.0.3"
      }
    },
    "node_modules/eventemitter3": {
      "version": "5.0.4",
      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/eyes": {
      "version": "0.1.8",
      "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
      "integrity": [REDACTED_SECRET],
      "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",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/humanize-ms": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ms": "^2.0.0"
      }
    },
    "node_modules/ieee754": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "BSD-3-Clause"
    },
    "node_modules/isomorphic-ws": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "peerDependencies": {
        "ws": "*"
      }
    },
    "node_modules/jayson": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/connect": "^3.4.33",
        "@types/node": "^12.12.54",
        "@types/ws": "^7.4.4",
        "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_modules/jayson/node_modules/commander": {
      "version": "2.20.3",
      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/json-stringify-safe": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC"
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/node-fetch": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "whatwg-url": "^5.0.0"
      },
      "engines": {
        "node": "4.x || >=6.0.0"
      },
      "peerDependencies": {
        "encoding": "^0.1.0"
      },
      "peerDependenciesMeta": {
        "encoding": {
          "optional": true
        }
      }
    },
    "node_modules/node-gyp-build": {
      "version": "4.8.4",
      "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "optional": true,
      "bin": {
        "node-gyp-build": "bin.js",
        "node-gyp-build-optional": "optional.js",
        "node-gyp-build-test": "build-test.js"
      }
    },
    "node_modules/rpc-websockets": {
      "version": "9.3.3",
      "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "LGPL-3.0-only",
      "dependencies": {
        "@swc/helpers": "^0.5.11",
        "@types/uuid": "^8.3.4",
        "@types/ws": "^8.2.2",
        "buffer": "^6.0.3",
        "eventemitter3": "^5.0.1",
        "uuid": "^8.3.2",
        "ws": "^8.5.0"
      },
      "funding": {
        "type": "paypal",
        "url": "https://paypal.me/kozjak"
      },
      "optionalDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": "^5.0.2"
      }
    },
    "node_modules/rpc-websockets/node_modules/@types/ws": {
      "version": "8.18.1",
      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/node": "*"
      }
    },
    "node_modules/rpc-websockets/node_modules/ws": {
      "version": "8.19.0",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    },
    "node_modules/safe-buffer": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/stream-chain": {
      "version": "2.2.5",
      "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
      "integrity": [REDACTED_SECRET],
      "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": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "stream-chain": "^2.2.5"
      }
    },
    "node_modules/superstruct": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/text-encoding-utf-8": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz",
      "integrity": [REDACTED_SECRET]
    },
    "node_modules/tr46": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "0BSD"
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "peer": true,
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "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": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "node-gyp-build": "^4.3.0"
      },
      "engines": {
        "node": ">=6.14.2"
      }
    },
    "node_modules/uuid": {
      "version": "8.3.2",
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "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",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause"
    },
    "node_modules/whatwg-url": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "tr46": "~0.0.3",
        "webidl-conversions": "^3.0.0"
      }
    },
    "node_modules/ws": {
      "version": "7.5.10",
      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8.3.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": "^5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    }
  }
}


path: package.json
content: {
  "name": "openclaw-solana-swap",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@solana/web3.js": "^1.98.0",
    "bs58": "^6.0.0"
  }
}


path: scripts/swap.mjs
content: #!/usr/bin/env node
// OpenClaw Solana Swap Skill (OSS) — CLI entrypoint
// Usage: node swap.mjs <prepare|execute|status|receipt> [options]

import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { Connection, Keypair, VersionedTransaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import bs58 from 'bs58';

// ── Config ──────────────────────────────────────────────────────────────────

const RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
const DEFAULT_SLIPPAGE = parseInt(process.env.OSS_DEFAULT_SLIPPAGE_BPS || '100', 10);
const PRIORITY_FEE_FLOOR = parseInt(process.env.OSS_PRIORITY_FEE_FLOOR || '50000', 10);
const JUPITER_BASE = 'https://lite-api.jup.ag/swap/v1';

const KNOWN_MINTS = {
  [REDACTED_SECRET]: { symbol: 'SOL', decimals: 9 },
  [REDACTED_SECRET]: { symbol: 'USDC', decimals: 6 },
  [REDACTED_SECRET]: { symbol: 'USDT', decimals: 6 },
  [REDACTED_SECRET]: { symbol: 'BONK', decimals: 5 },
};

const SOL_MINT = [REDACTED_SECRET];

// ── Helpers ─────────────────────────────────────────────────────────────────

function output(obj) {
  process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
}

function fail(code, message, retryable = false, details) {
  output({ error: { code, message, retryable, ...(details ? { details } : {}) } });
  process.exit(1);
}

function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a.startsWith('--')) {
      const key = a.slice(2);
      const next = argv[i + 1];
      if (!next || next.startsWith('--')) {
        args[key] = true; // flag
      } else {
        args[key] = next;
        i++;
      }
    }
  }
  return args;
}

function validatePubkey(value, label) {
  try {
    const bytes = bs58.decode(value);
    if (bytes.length !== 32) throw new Error('not 32 bytes');
    return new PublicKey(value);
  } catch {
    fail('INVALID_INPUT', `Invalid ${label}: not a valid base58 pubkey`);
  }
}

function loadKeypair() {
  const kpPath = process.env.SOLANA_KEYPAIR_PATH;
  if (!kpPath) fail('KEYPAIR_NOT_FOUND', 'SOLANA_KEYPAIR_PATH env var is not set');
  if (!existsSync(kpPath)) fail('KEYPAIR_NOT_FOUND', `Keypair file not found: ${kpPath}`);
  try {
    const raw = readFileSync(kpPath, 'utf-8');
    return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw)));
  } catch (e) {
    fail('KEYPAIR_INVALID', 'Could not parse keypair file');
  }
}

function ownerPubkey() {
  const kpPath = process.env.SOLANA_KEYPAIR_PATH;
  if (!kpPath) fail('KEYPAIR_NOT_FOUND', 'SOLANA_KEYPAIR_PATH env var is not set');
  if (!existsSync(kpPath)) fail('KEYPAIR_NOT_FOUND', `Keypair file not found: ${kpPath}`);
  try {
    const raw = readFileSync(kpPath, 'utf-8');
    return Keypair.fromSecretKey(Uint8Array.from(JSON.parse(raw))).publicKey;
  } catch {
    fail('KEYPAIR_INVALID', 'Could not parse keypair file');
  }
}

function prepareFilePath(id) {
  const cacheDir = new URL('../.cache', import.meta.url).pathname;
  if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
  return `${cacheDir}/prepare-${id}.json`;
}

function formatAmount(amount, mint) {
  const info = KNOWN_MINTS[mint];
  if (!info) return amount;
  const decimals = info.decimals;
  const raw = BigInt(amount);
  const divisor = BigInt(10 ** decimals);
  const whole = raw / divisor;
  const frac = raw % divisor;
  const fracStr = frac.toString().padStart(decimals, '0');
  // Show enough decimal places: at least 2, but more if needed to show non-zero digits
  let sigDigits = 2;
  if (whole === 0n) {
    // Find first non-zero digit in fraction
    const firstNonZero = fracStr.search(/[1-9]/);
    if (firstNonZero >= 0) sigDigits = Math.max(sigDigits, firstNonZero + 2);
  }
  const truncated = fracStr.slice(0, sigDigits);
  return `${whole}.${truncated} ${info.symbol}`;
}

function getConnection() {
  return new Connection(RPC_URL, 'confirmed');
}

// ── Commands ────────────────────────────────────────────────────────────────

async function cmdPrepare(args) {
  // Validate required inputs
  if (!args.from) fail('INVALID_INPUT', 'Missing --from (input mint)');
  if (!args.to) fail('INVALID_INPUT', 'Missing --to (output mint)');
  if (!args.amount) fail('INVALID_INPUT', 'Missing --amount (base units)');

  const fromMint = args.from;
  const toMint = args.to;
  validatePubkey(fromMint, 'from mint');
  validatePubkey(toMint, 'to mint');

  const amountIn = args.amount;
  if (!/^\d+$/.test(amountIn) || BigInt(amountIn) <= 0n) {
    fail('INVALID_INPUT', 'Amount must be a positive integer (base units)');
  }

  const slippage = parseInt(args.slippage || String(DEFAULT_SLIPPAGE), 10);
  if (isNaN(slippage) || slippage < 1 || slippage > 2000) {
    fail('INVALID_INPUT', 'Slippage must be 1–2000 bps');
  }

  const priorityFee = args.priorityFee ? parseInt(args.priorityFee, 10) : PRIORITY_FEE_FLOOR;
  if (isNaN(priorityFee) || priorityFee < 0) {
    fail('INVALID_INPUT', 'Priority fee must be a non-negative integer');
  }
  const maxLamports = Math.max(priorityFee, PRIORITY_FEE_FLOOR);

  const expirySeconds = parseInt(args.expiry || '120', 10);
  if (isNaN(expirySeconds) || expirySeconds < 30 || expirySeconds > 300) {
    fail('INVALID_INPUT', 'Expiry must be 30–300 seconds');
  }

  let destination = null;
  if (args.destination) {
    if (!args.allowThirdParty) {
      fail('INVALID_INPUT', 'Third-party destination requires --allowThirdParty flag');
    }
    destination = validatePubkey(args.destination, 'destination');
  }

  // Derive owner
  const owner = ownerPubkey();
  const conn = getConnection();

  // Pre-flight SOL balance check
  try {
    const balance = await conn.getBalance(owner);
    const estimatedFee = 5000 + maxLamports; // base fee + priority
    let needed = estimatedFee;
    if (fromMint === SOL_MINT) needed += Number(BigInt(amountIn));

    if (balance < needed) {
      fail('INSUFFICIENT_SOL', `Not enough SOL for fees. Have ${(balance / LAMPORTS_PER_SOL).toFixed(4)} SOL, need ~${(needed / LAMPORTS_PER_SOL).toFixed(4)} SOL`, false, {
        have: balance,
        need: needed,
      });
    }
  } catch (e) {
    if (e.code) throw e; // re-throw structured errors
    fail('RPC_UNAVAILABLE', `RPC error checking balance: ${e.message}`, true);
  }

  // Jupiter quote
  let quoteResponse;
  try {
    const quoteUrl = `${JUPITER_BASE}/quote?inputMint=${fromMint}&outputMint=${toMint}&amount=${amountIn}&slippageBps=${slippage}`;
    const res = await fetch(quoteUrl);
    if (!res.ok) {
      const body = await res.text();
      if (res.status >= 500) fail('BACKEND_UNAVAILABLE', `Jupiter quote error: ${res.status}`, true);
      fail('BACKEND_QUOTE_FAILED', `Jupiter quote failed: ${res.status} ${body}`);
    }
    quoteResponse = await res.json();
  } catch (e) {
    if (e.code) throw e;
    fail('BACKEND_UNAVAILABLE', `Jupiter unreachable: ${e.message}`, true);
  }

  // Jupiter swap (build tx)
  let swapTxBase64;
  try {
    const swapBody = {
      quoteResponse,
      userPublicKey: owner.toBase58(),
      dynamicComputeUnitLimit: true,
      prioritizationFeeLamports: {
        priorityLevelWithMaxLamports: {
          maxLamports,
          priorityLevel: 'high',
        },
      },
    };
    if (destination) swapBody.destinationTokenAccount = destination.toBase58();

    const res = await fetch(`${JUPITER_BASE}/swap`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(swapBody),
    });
    if (!res.ok) {
      const body = await res.text();
      if (res.status >= 500) fail('BACKEND_UNAVAILABLE', `Jupiter swap build error: ${res.status}`, true);
      fail('BACKEND_QUOTE_FAILED', `Jupiter swap build failed: ${res.status} ${body}`);
    }
    const swapData = await res.json();
    swapTxBase64 = swapData.swapTransaction;
    if (!swapTxBase64) fail('BACKEND_QUOTE_FAILED', 'Jupiter returned no swap transaction');
  } catch (e) {
    if (e.code) throw e;
    fail('BACKEND_UNAVAILABLE', `Jupiter swap build unreachable: ${e.message}`, true);
  }

  // Store prepared swap
  const prepareId = randomUUID();
  const expiresAt = new Date(Date.now() + expirySeconds * 1000).toISOString();

  const prepared = {
    prepareId,
    txBase64: swapTxBase64,
    fromMint,
    toMint,
    amountIn,
    slippage,
    owner: owner.toBase58(),
    destination: destination ? destination.toBase58() : owner.toBase58(),
    expiresAt,
    executed: false,
    expectedOut: quoteResponse.outAmount || null,
    minOut: quoteResponse.otherAmountThreshold || null,
    priceImpact: quoteResponse.priceImpactPct || null,
  };

  writeFileSync(prepareFilePath(prepareId), JSON.stringify(prepared));

  // Build summary
  const summary = {
    from: formatAmount(amountIn, fromMint),
    to: prepared.expectedOut ? `~${formatAmount(prepared.expectedOut, toMint)}` : 'unknown',
    minReceived: prepared.minOut ? formatAmount(prepared.minOut, toMint) : 'unknown',
    slippage: `${slippage / 100}%`,
    priceImpact: prepared.priceImpact ? `${prepared.priceImpact}%` : 'unknown',
    destination: destination ? destination.toBase58() : 'owner',
  };

  output({
    prepareId,
    expectedOut: prepared.expectedOut,
    minOut: prepared.minOut,
    priceImpact: prepared.priceImpact,
    expiresAt,
    summary,
  });
}

async function cmdExecute(args) {
  if (!args.prepareId) fail('INVALID_INPUT', 'Missing --prepareId');

  const filePath = prepareFilePath(args.prepareId);
  if (!existsSync(filePath)) fail('INVALID_INPUT', `Unknown prepareId: ${args.prepareId}`);

  const prepared = JSON.parse(readFileSync(filePath, 'utf-8'));

  if (prepared.executed) {
    fail('PREPARE_ALREADY_EXECUTED', 'This swap has already been executed');
  }

  if (new Date(prepared.expiresAt) < new Date()) {
    fail('PREPARE_EXPIRED', 'This prepared swap has expired — run prepare again', true);
  }

  // Sign
  const keypair = loadKeypair();
  let signedTx;
  try {
    const txBuf = Buffer.from(prepared.txBase64, 'base64');
    const tx = VersionedTransaction.deserialize(txBuf);
    tx.sign([keypair]);
    signedTx = tx.serialize();
  } catch (e) {
    fail('TX_BROADCAST_FAILED', `Failed to deserialize/sign transaction: ${e.message}`, true);
  }

  // Send
  const conn = getConnection();
  let signature;
  try {
    signature = await conn.sendRawTransaction(signedTx, { skipPreflight: false });
  } catch (e) {
    if (e.message && e.message.includes('block height exceeded')) {
      fail('TX_EXPIRED', 'Transaction blockhash expired — run prepare again', true);
    }
    fail('TX_BROADCAST_FAILED', `Failed to broadcast: ${e.message}`, true);
  }

  // Mark executed
  prepared.executed = true;
  prepared.signature = signature;
  writeFileSync(filePath, JSON.stringify(prepared));

  output({
    signature,
    submittedAt: new Date().toISOString(),
  });
}

async function cmdStatus(args) {
  if (!args.signature) fail('INVALID_INPUT', 'Missing --signature');

  const conn = getConnection();
  try {
    const res = await conn.getSignatureStatuses([args.signature]);
    const status = res?.value?.[0];

    if (!status) {
      output({ state: 'unknown', signature: args.signature });
      return;
    }

    if (status.err) {
      output({
        state: 'failed',
        signature: args.signature,
        slot: status.slot,
        confirmationStatus: status.confirmationStatus,
        err: status.err,
      });
      return;
    }

    const state = status.confirmationStatus === 'finalized' || status.confirmationStatus === 'confirmed'
      ? 'confirmed' : 'submitted';

    output({
      state,
      signature: args.signature,
      slot: status.slot,
      confirmationStatus: status.confirmationStatus,
    });
  } catch (e) {
    fail('RPC_UNAVAILABLE', `RPC error: ${e.message}`, true);
  }
}

async function cmdReceipt(args) {
  if (!args.signature) fail('INVALID_INPUT', 'Missing --signature');

  const conn = getConnection();
  let txInfo;
  try {
    txInfo = await conn.getTransaction(args.signature, {
      maxSupportedTransactionVersion: 0,
    });
  } catch (e) {
    fail('RPC_UNAVAILABLE', `RPC error: ${e.message}`, true);
  }

  if (!txInfo) {
    fail('INVALID_INPUT', 'Transaction not found — it may not be confirmed yet', true);
  }

  if (txInfo.meta?.err) {
    fail('TX_FAILED_ONCHAIN', `Transaction failed onchain: ${JSON.stringify(txInfo.meta.err)}`);
  }

  // Parse token balance diffs — filter to signer's accounts only
  const pre = txInfo.meta?.preTokenBalances || [];
  const post = txInfo.meta?.postTokenBalances || [];

  // The signer is account index 0; get their pubkey from accountKeys
  const signerPubkey = txInfo.transaction?.message?.staticAccountKeys?.[0]?.toBase58?.()
    || txInfo.transaction?.message?.accountKeys?.[0]?.toBase58?.();

  // Build balance map keyed by mint, filtered to signer's token accounts
  const balances = {};
  for (const b of pre) {
    if (b.owner !== signerPubkey) continue;
    const key = b.mint;
    if (!balances[key]) balances[key] = { pre: '0', post: '0' };
    balances[key].pre = b.uiTokenAmount?.amount || '0';
  }
  for (const b of post) {
    if (b.owner !== signerPubkey) continue;
    const key = b.mint;
    if (!balances[key]) balances[key] = { pre: '0', post: '0' };
    balances[key].post = b.uiTokenAmount?.amount || '0';
  }

  // Find the token that decreased (input) and increased (output) for the signer
  let inputMint = null, outputMint = null;
  let amountInActual = null, amountOutActual = null;

  for (const [mint, bal] of Object.entries(balances)) {
    const diff = BigInt(bal.post) - BigInt(bal.pre);
    if (diff < 0n) {
      inputMint = mint;
      amountInActual = (diff * -1n).toString();
    } else if (diff > 0n) {
      outputMint = mint;
      amountOutActual = diff.toString();
    }
  }

  // SOL balance diff for fee / SOL swaps
  const preSol = txInfo.meta?.preBalances?.[0] || 0;
  const postSol = txInfo.meta?.postBalances?.[0] || 0;
  const solDiff = postSol - preSol;
  const feeLamports = (txInfo.meta?.fee || 0).toString();

  // If no token input found but SOL decreased, it's a SOL→token swap
  if (!inputMint && solDiff < 0) {
    inputMint = SOL_MINT;
    amountInActual = (Math.abs(solDiff) - Number(feeLamports)).toString();
  }
  if (!outputMint && solDiff > 0) {
    outputMint = SOL_MINT;
    amountOutActual = solDiff.toString();
  }

  output({
    signature: args.signature,
    confirmedAt: txInfo.blockTime ? new Date(txInfo.blockTime * 1000).toISOString() : null,
    inputMint,
    outputMint,
    amountInActual,
    amountOutActual,
    feeLamports,
    solscanUrl: `https://solscan.io/tx/${args.signature}`,
  });
}

// ── Main ────────────────────────────────────────────────────────────────────

const [command, ...rest] = process.argv.slice(2);
const args = parseArgs(rest);

switch (command) {
  case 'prepare': await cmdPrepare(args); break;
  case 'execute': await cmdExecute(args); break;
  case 'status':  await cmdStatus(args); break;
  case 'receipt': await cmdReceipt(args); break;
  default:
    output({
      usage: 'node swap.mjs <prepare|execute|status|receipt> [options]',
      commands: {
        prepare: '--from <mint> --to <mint> --amount <baseUnits> [--slippage <bps>] [--priorityFee <lamports>] [--destination <pubkey> --allowThirdParty] [--expiry <seconds>]',
        execute: '--prepareId <id>',
        status: '--signature <sig>',
        receipt: '--signature <sig>',
      },
    });
}


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)