diff --git a/README.md b/README.md index a30d9d4..0038db0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Each skill is a self-contained directory with a `SKILL.md` (used by Claude Code | [validation](./validation/) | `validation/validation.ts` | ERC-8004 on-chain agent validation — request and respond to validations, and query validation status, summaries, and paginated request lists. | | [bitflow](./bitflow/) | `bitflow/bitflow.ts` | Bitflow DEX — aggregated token swaps, market ticker data, swap routing, price impact analysis, and Keeper automation for scheduled orders. Mainnet-only. | | [defi](./defi/) | `defi/defi.ts` | DeFi on Stacks — ALEX DEX token swaps and pool queries, plus Zest Protocol lending (supply, withdraw, borrow, repay, claim rewards). Mainnet-only. | +| [launkr](./launkr/) | `launkr/launkr.ts` | Launkr protected token launcher and XYK AMM — launch a restricted SIP-010 token (bonding or direct pool), read pool state, quote, and swap STX for tokens through the singleton. Mainnet + testnet. | | [defi-portfolio-scanner](./defi-portfolio-scanner/) | `defi-portfolio-scanner/defi-portfolio-scanner.ts` | Cross-protocol DeFi position aggregator — scans Bitflow HODLMM, Zest Protocol, ALEX DEX, and Styx Bridge for a given STX address; produces a risk-scored portfolio summary with USD estimates. Mainnet-only. | | [zest-auto-repay](./zest-auto-repay/) | `zest-auto-repay/zest-auto-repay.ts` | Autonomous Zest Protocol LTV guardian — monitors borrowing positions, detects liquidation risk, and executes safe repayments with enforced spend limits to protect collateral on Stacks mainnet. | | [hodlmm-move-liquidity](./hodlmm-move-liquidity/) | `hodlmm-move-liquidity/hodlmm-move-liquidity.ts` | HODLMM Move-Liquidity & Auto-Rebalancer — withdraw from drifted bins, re-deposit around the current active bin, and run an autonomous monitoring loop that keeps LP capital earning 24/7. Mainnet-only. | diff --git a/launkr/AGENT.md b/launkr/AGENT.md new file mode 100644 index 0000000..983dc4c --- /dev/null +++ b/launkr/AGENT.md @@ -0,0 +1,96 @@ +--- +name: launkr-agent +skill: launkr +description: "Launch and trade restricted SIP-010 tokens on Launkr — a protected token launcher and XYK AMM on Stacks. Deploy a token, open a bonding or direct pool, and trade STX for tokens." +--- + +# Launkr — Autonomous Operation Rules + +Rules for an agent using the Launkr skill (`launkr.ts`) without a human +approving each step. Read `SKILL.md` first for the protocol reference — +this file is about *how to behave*, not the API shape. + +## Before doing anything + +1. **Fetch `GET /api/protocol?network=` fresh, every session.** + Contract addresses have changed once already (2026-07-16 mainnet + redeploy). Never hardcode an address from memory or from an old run. +2. **Know which network you're on.** Testnet and mainnet contracts are + structurally identical but financially very different — testnet STX is + free from a faucet, mainnet STX is real money. The skill follows the + `NETWORK` env var (default `testnet`) — the same one the wallet loads + against — so set `NETWORK=mainnet` deliberately rather than assuming a + default. There is no per-command `--network` flag. +3. **This is an early-stage mainnet deployment.** Redeployed 2026-07-16. + Start with small amounts (floor-minimum supply, minimum virtual-stx) on + any new integration before scaling up, even though the contract has + passed a live end-to-end test. + +## Launching a token + +1. Never edit the `clarityCode` returned by `/api/launch`, not even + whitespace — it must stay byte-identical to the deployed template or the + singleton's hash gate rejects it (`ERR_TOKEN_NOT_OURS u201`). +2. Always wait for the deploy transaction (step 1) to reach + `tx_status: "success"` before sending the pool-creation call (step 2). + Do not assume success from a `200` on broadcast — poll + `GET /extended/v1/tx/{txid}` and check the status field. +3. The optional `uri` argument is a genuine Clarity optional. When you don't + pass `--uri`, the `/api/launch` intent carries `uri: null` and `launkr.ts` + encodes it as `(none)` — that is valid for `(optional (string-utf8 256))` + and broadcasts fine. Do not substitute a `Some("")` or any placeholder + string. +4. For **direct mode**, confirm you actually hold ≥ `stxSeed` uSTX before + attempting the call — it pulls real STX from your balance at creation + time, guarded by an exact STX post-condition. Insufficient balance fails + loudly (post-condition or balance check), it does not silently partial-fill. +5. Pick `virtualStx`/`graduationThreshold` (bonding) deliberately, not just + at the floor minimums, unless the goal is specifically a cheap test + token — floor values create a very "top-heavy" curve (large price impact + per STX traded). + +## Trading (quote / swap) + +1. Always call `quote-buy`/`quote-sell` immediately before a swap and derive + `min-tokens-out`/`min-stx-out` from that quote with a slippage tolerance + (1–2% is reasonable for a low-liquidity bonding pool; widen it if the + pool is thin or volatile). Never hardcode a slippage guard without a + fresh quote — pool state changes with every trade. +2. Treat a `none` result from `quote-buy`/`quote-sell` as "do not proceed" — + it means the pool doesn't exist or your input amount is zero, not "any + amount is fine." +3. `swap-sell` scopes this for you: it broadcasts in `Deny` mode with a + fungible post-condition equal to `--tokens-in` on the token sold (asset + name is always `strategy-token`, since every token is a byte-identical + copy of the template — see `SKILL.md`). Don't switch it back to + `PostConditionMode.Allow`: allow-mode on a live wallet means an unexpected + contract bug could move more of your balance than intended, with nothing + to catch it. +4. Set a `deadline` when the surrounding context is time-sensitive (e.g. + part of a multi-step flow where a stale price is a real risk). Only fall + back to `0xffffffff` (no deadline) for one-off manual actions. + +## Error handling + +- Don't retry a rejected broadcast blindly. Read the rejection reason first + — `BadFunctionArgument`, a post-condition failure, and a real contract + `(err uNNN)` all need different fixes, and blind retries can burn gas + repeatedly on the same mistake. +- Map `(err uNNN)` results to the error table in `SKILL.md` before deciding + what to do next — several of them (e.g. `u209`/`u220`/`u224`) mean your + launch parameters are mathematically invalid for the curve, not that + something is broken. + +## What NOT to do + +- Don't launch a token with a `feeReceiver` you don't control unless that's + explicitly the intent — it receives 90% of all swap fees on that pool, + permanently (a two-step transfer exists on-chain, but don't rely on + needing it). +- Don't assume a pool is safe to trade at size just because it exists. + `get-pool` first — check `active`, `mode`, and current reserves before + committing meaningful STX to a swap. +- Don't skip the confirmation-wait between launch steps to save time. A + pool-creation call against an unconfirmed (or failed) token deploy will + fail outright, and diagnosing "why" after the fact costs more time than + the wait would have. diff --git a/launkr/SKILL.md b/launkr/SKILL.md new file mode 100644 index 0000000..8244e18 --- /dev/null +++ b/launkr/SKILL.md @@ -0,0 +1,397 @@ +--- +name: launkr +description: "Launch and trade restricted SIP-010 tokens on Launkr — a protected token launcher and XYK AMM on Stacks. Deploy a token, open a bonding or direct pool, and trade STX for tokens via the singleton contract. Works on both mainnet and testnet." +metadata: + author: "rather-labs" + author-agent: "Launkr by Rather Labs" + user-invocable: "false" + arguments: "launch | get-pool | quote-buy | quote-sell | swap-buy | swap-sell" + entry: "launkr/launkr.ts" + mcp-tools: "deploy_contract, call_contract, call_read_only_function" + requires: "wallet" + tags: "l2, defi, write, requires-funds" +--- + +# Launkr Skill + +Launch and trade restricted SIP-010 tokens on [Launkr](https://launkr.io) — a protected token launcher and AMM on the Stacks blockchain, built by [Rather Labs](https://ratherlabs.com). + +**What Launkr is:** A singleton XYK AMM that hosts N pools. Each pool trades STX against a *restricted* SIP-010 token — a token whose `transfer` function is locked so all trading must go through the authorized singleton. This guarantees fee capture on every swap. + +**Two pool modes:** +- **Bonding** (`create-pool-bonding`) — Starts with virtual reserves. No STX seed required. Fees: 1%. Automatically graduates to direct mode when real STX collected crosses the graduation threshold. +- **Direct** (`create-pool-direct`) — Starts with a real STX seed (≥ 100 STX). Fees: 5%. + +**Hash gate — critical:** The singleton verifies that each token's source is byte-identical to the on-chain template. Always fetch the template source verbatim from the Launkr API or from the Hiro API — never modify it. + +--- + +## Protocol Info + +Get contract IDs, floors, and fee schedule (call this first): + +``` +GET https://launkr.io/api/protocol?network=mainnet +GET https://launkr.io/api/protocol?network=testnet +``` + +**Mainnet contracts:** +- Singleton: `SP2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9Z367PM.lp-singleton-v6` +- Template: `SP2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9Z367PM.restricted-token-template-v6` +- Trait: `SP2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9Z367PM.restricted-ft-trait-v6` + +**Testnet contracts:** +- Singleton: `ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE.lp-singleton-v6` +- Template: `ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE.restricted-token-template-v6` +- Trait: `ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE.restricted-ft-trait-v6` + +--- + +## How to Launch a Token + +Launching requires two sequential transactions. **Do not send step 2 until step 1 confirms.** + +### Step 1 — Get the launch intent + +Call the Launkr API to receive the verbatim template source and the exact call intent: + +```http +POST https://launkr.io/api/launch +Content-Type: application/json + +{ + "network": "mainnet", + "deployerAddress": "", + "name": "My Agent Token", + "symbol": "MAT", + "supply": "1000000000000000", + "mode": "bonding", + "virtualStx": "500000000", + "graduationThreshold": "2000000000", + "feeReceiver": "" +} +``` + +Response: +```json +{ + "tokenPrincipal": "SP2....my-agent-token", + "singletonId": "SP2....lp-singleton-v6", + "steps": [ + { + "step": 1, + "kind": "contract-deploy", + "contractName": "my-agent-token", + "clarityVersion": 4, + "clarityCode": "" + }, + { + "step": 2, + "kind": "contract-call", + "contractId": "SP2....lp-singleton-v6", + "functionName": "create-pool-bonding", + "functionArgs": [...] + } + ] +} +``` + +### Step 2 — Deploy the token contract + +Save `clarityCode` from the response to a temp file, then deploy it using `deploy_contract`: + +``` +deploy_contract( + source="/tmp/my-agent-token.clar", + name="my-agent-token" +) +``` + +Wait for the transaction to reach `success` status before continuing. +Check: `GET https://api.hiro.so/extended/v1/tx/{txid}` + +### Step 3 — Create the pool + +Use `call_contract` with the `functionName` and `functionArgs` from the API response. + +**Bonding pool** (no STX pulled at creation — post-conditions array is empty): +``` +call_contract( + contract="SP2....lp-singleton-v6", + function="create-pool-bonding", + args=[ + {"type":"principal","value":"SP2....my-agent-token"}, + {"type":"string-ascii","value":"My Agent Token"}, + {"type":"string-ascii","value":"MAT"}, + {"type":"uint","value":"6"}, + {"type":"uint","value":"1000000000000000"}, + {"type":"(optional (string-utf8 256))","value":null}, + {"type":"uint","value":"500000000"}, + {"type":"uint","value":"2000000000"}, + {"type":"principal","value":""} + ], + postConditionMode="deny", + postConditions=[] +) +``` + +**Direct pool** (pulls `stxSeed` uSTX from tx-sender — include the post-condition): +``` +call_contract( + contract="SP2....lp-singleton-v6", + function="create-pool-direct", + args=[ + {"type":"principal","value":"SP2....my-agent-token"}, + {"type":"string-ascii","value":"My Agent Token"}, + {"type":"string-ascii","value":"MAT"}, + {"type":"uint","value":"6"}, + {"type":"uint","value":"1000000000000000"}, + {"type":"(optional (string-utf8 256))","value":null}, + {"type":"uint","value":"100000000"}, + {"type":"principal","value":""} + ], + postConditionMode="deny", + postConditions=[ + {"type":"stx","principal":"","conditionCode":"eq","amount":"100000000"} + ] +) +``` + +--- + +## Subcommands + +### launch + +Launch a new token on Launkr (two sequential transactions, automatic confirmation wait). + +``` +bun run launkr/launkr.ts launch \ + --name "My Agent Token" \ + --symbol MAT \ + --supply 1000000000000000 \ + --mode bonding \ + --virtual-stx 500000000 \ + --graduation-threshold 2000000000 \ + --fee-receiver \ + [--uri ]``` + +For direct mode, replace `--virtual-stx` and `--graduation-threshold` with `--stx-seed ` (min `100000000`). + +Output: +```json +{ + "success": true, + "tokenPrincipal": "SP2....my-agent-token", + "deployTxid": "abc123...", + "poolTxid": "def456...", + "network": "mainnet", + "explorerUrl": "https://explorer.hiro.so/txid/def456...?chain=mainnet", + "launkrUrl": "https://launkr.io/token/SP2....my-agent-token" +} +``` + +### get-pool + +Get pool state for a token. No wallet required. + +``` +bun run launkr/launkr.ts get-pool \ + --token ``` + +Output: +```json +{ + "token": "SP2....my-token", + "mode": "bonding", + "stxReserve": "0", + "tokenReserve": "1000000000000000", + "virtualStx": "500000000", + "graduationThreshold": "2000000000", + "bondedStxCollected": "12300000", + "active": true, + "feeReceiver": "SP2....", + "network": "mainnet" +} +``` + +Pool mode values: `direct` (u0), `bonding` (u1), `graduated` (u2). + +### quote-buy + +Simulate a buy — returns tokens out net of fees. No wallet required. + +``` +bun run launkr/launkr.ts quote-buy \ + --token \ + --stx-in ``` + +Output: +```json +{ + "token": "SP2....my-token", + "stxIn": "10000000", + "tokensOut": "1952380952", + "network": "mainnet" +} +``` + +### quote-sell + +Simulate a sell — returns STX out net of fees. No wallet required. + +``` +bun run launkr/launkr.ts quote-sell \ + --token \ + --tokens-in ``` + +Output: +```json +{ + "token": "SP2....my-token", + "tokensIn": "1000000000", + "stxOut": "4950000", + "network": "mainnet" +} +``` + +### swap-buy + +Buy tokens with STX. Requires an unlocked wallet. + +``` +bun run launkr/launkr.ts swap-buy \ + --token \ + --stx-in \ + --min-tokens-out \ + [--deadline ] \ + [--recipient ]``` + +Options: +- `--token` (required) — Full token principal (e.g. `SP2....my-token`) +- `--stx-in` (required) — uSTX to spend +- `--min-tokens-out` (required) — Slippage guard: minimum tokens to receive (run `quote-buy` first, apply 1–2% tolerance) +- `--deadline` (optional) — Max Stacks block height (default: `4294967295`, no deadline) +- `--recipient` (optional) — Address to receive tokens (default: wallet address) + +Output: +```json +{ + "success": true, + "txid": "abc123...", + "stxIn": "10000000", + "minTokensOut": "1900000000", + "network": "mainnet", + "explorerUrl": "https://explorer.hiro.so/txid/abc123...?chain=mainnet" +} +``` + +### swap-sell + +Sell tokens for STX. Requires an unlocked wallet. + +``` +bun run launkr/launkr.ts swap-sell \ + --token \ + --tokens-in \ + --min-stx-out \ + [--deadline ] \ + [--recipient ]``` + +Options: +- `--token` (required) — Full token principal +- `--tokens-in` (required) — Atomic token units to sell +- `--min-stx-out` (required) — Slippage guard: minimum uSTX to receive (run `quote-sell` first) +- `--deadline` (optional) — Max Stacks block height +- `--recipient` (optional) — Address to receive STX (default: wallet address) + +Output: +```json +{ + "success": true, + "txid": "abc123...", + "tokensIn": "1000000000", + "minStxOut": "4800000", + "network": "mainnet", + "explorerUrl": "https://explorer.hiro.so/txid/abc123...?chain=mainnet" +} +``` + +--- + +## Arguments + +| Subcommand | Option | Required | Description | +|------------|--------|----------|-------------| +| `launch` | `--name` | yes | Token display name (max 32 chars) | +| `launch` | `--symbol` | yes | Token symbol (max 32 chars) | +| `launch` | `--supply` | yes | Total supply in atomic units (min `100000000000000` = 100M @ 6 dec) | +| `launch` | `--mode` | yes | `bonding` or `direct` | +| `launch` | `--fee-receiver` | yes | STX address that receives 90% of swap fees | +| `launch` | `--virtual-stx` | bonding only | Virtual STX reserve in uSTX (min `500000000`) | +| `launch` | `--graduation-threshold` | bonding only | Real STX to collect before graduating (min `2000000000`, max 10× virtual-stx and `10000000000000`) | +| `launch` | `--stx-seed` | direct only | Real STX to seed the pool in uSTX (min `100000000`) | +| `launch` | `--uri` | no | Optional token metadata URI | +| `launch` | `--fee` | no | Fee preset (`low`\|`medium`\|`high`) or explicit micro-STX amount (default: auto-estimate) | +| `get-pool` | `--token` | yes | Full token principal (`ADDRESS.contract-name`) | +| `quote-buy` | `--token` | yes | Full token principal | +| `quote-buy` | `--stx-in` | yes | uSTX to spend | +| `quote-sell` | `--token` | yes | Full token principal | +| `quote-sell` | `--tokens-in` | yes | Atomic token units to sell | +| `swap-buy` | `--token` | yes | Full token principal | +| `swap-buy` | `--stx-in` | yes | uSTX to spend | +| `swap-buy` | `--min-tokens-out` | yes | Slippage guard (use `quote-buy` first) | +| `swap-buy` | `--deadline` | no | Max block height (default: no deadline) | +| `swap-buy` | `--recipient` | no | Recipient address (default: wallet address) | +| `swap-buy` | `--fee` | no | Fee preset or micro-STX amount (default: auto-estimate) | +| `swap-sell` | `--token` | yes | Full token principal | +| `swap-sell` | `--tokens-in` | yes | Atomic token units to sell | +| `swap-sell` | `--min-stx-out` | yes | Slippage guard (use `quote-sell` first) | +| `swap-sell` | `--deadline` | no | Max block height | +| `swap-sell` | `--recipient` | no | Recipient address | +| `swap-sell` | `--fee` | no | Fee preset or micro-STX amount (default: auto-estimate) | + +**Network:** All subcommands act on the network selected by the `NETWORK` environment variable (default `testnet`) — the same source the wallet and every other skill use. There is no `--network` flag. Prefix a command with `NETWORK=mainnet` to target mainnet, e.g. `NETWORK=mainnet bun run launkr/launkr.ts get-pool --token `. + +--- + +## Protocol Floors + +The singleton enforces these on-chain — the API validates them before returning an intent, but apply them when calling the contract directly. + +| Parameter | Minimum | Maximum | +|-----------|---------|---------| +| supply (atomic units) | `100000000000000` | `1000000000000000000000` | +| decimals | — | `18` | +| stxSeed (direct mode) | `100000000` (100 STX) | — | +| virtualStx (bonding mode) | `500000000` (500 STX) | — | +| graduationThreshold | `2000000000` (2000 STX) | `10000000000000` (10M STX) | +| graduationThreshold | — | 10× virtualStx | + +--- + +## Error Codes + +| Code | Error | Cause | +|------|-------|-------| +| u200 | `ERR_POOL_EXISTS` | A pool already exists for this token | +| u201 | `ERR_TOKEN_NOT_OURS` | Token source doesn't match the approved hash | +| u209 | `ERR_VIRTUAL_RATIO_TOO_LOW` | virtualStx too low relative to supply | +| u217 | `ERR_NOT_GRADUATED` | `swap-and-burn` called on a bonding pool | +| u220 | `ERR_RATIO_TOO_HIGH` | graduationThreshold > 10× virtualStx | +| u221 | `ERR_GRADUATION_TOO_HIGH` | graduationThreshold > 10M STX | +| u224 | `ERR_DEGENERATE_CURVE` | < 50% of supply would be released at graduation | + +--- + +## Notes + +- **Hash gate:** Every token deployed must be byte-identical to `restricted-token-template-v6`. Use `POST https://launkr.io/api/launch` to get the verbatim source. Never edit the source code — any change causes `ERR_TOKEN_NOT_OURS u201` on `create-pool`. +- **Confirm before step 2:** Wait for the deploy tx to reach `success` on-chain before calling `create-pool-*`. The singleton reads the deployed contract's source at pool creation time. +- **No seed for bonding:** `create-pool-bonding` pulls no STX at creation. The pool starts with virtual reserves only. The post-conditions array is empty. +- **Quote before swap:** Always call `quote-buy` or `quote-sell` first. Apply a 1–2% slippage tolerance to derive `min-tokens-out` / `min-stx-out`. +- **Post-conditions:** Both swaps run in `Deny` mode. `swap-buy` scopes an STX post-condition equal to `--stx-in`. `swap-sell` scopes a fungible post-condition equal to `--tokens-in` on the token you sell — its SIP-010 asset name is always `strategy-token` (every token is a byte-identical copy of `restricted-token-template-v6`), so a sell can never move more of your tokens than the amount you passed. The STX the singleton pays back is authorized by the contract's own Clarity-4 `as-contract?` allowance. +- **Graduated pools:** Once a bonding pool crosses its `graduationThreshold`, it permanently becomes a direct (5% fee) pool. `swap-and-burn` then becomes available — fee-free, permissionless deflation. +- **Pool visibility:** All pools — including agent-launched ones — appear on `launkr.io` automatically. The frontend indexes pools via Hiro API events. +- **Testnet faucet:** https://explorer.hiro.so/sandbox/faucet?chain=testnet +- **Full protocol reference:** https://launkr.io/api/protocol diff --git a/launkr/launkr.ts b/launkr/launkr.ts new file mode 100644 index 0000000..4071191 --- /dev/null +++ b/launkr/launkr.ts @@ -0,0 +1,761 @@ +#!/usr/bin/env bun +/** + * Launkr skill CLI + * Launch and trade restricted SIP-010 tokens on the Launkr protected AMM (Stacks blockchain). + * + * Usage: bun run launkr/launkr.ts [options] + */ + +import { Command } from "commander"; +import { + contractPrincipalCV, + standardPrincipalCV, + uintCV, + stringAsciiCV, + stringUtf8CV, + noneCV, + someCV, + serializeCV, + deserializeCV, + cvToValue, + ClarityVersion, + PostConditionMode, + type ClarityValue, +} from "@stacks/transactions"; +import { NETWORK, getApiBaseUrl, getExplorerTxUrl } from "../src/lib/config/networks.js"; +import { getAccount, getWalletAddress } from "../src/lib/services/x402.service.js"; +import { callContract, deployContract } from "../src/lib/transactions/builder.js"; +import { + createStxPostCondition, + createFungiblePostCondition, +} from "../src/lib/transactions/post-conditions.js"; +import { resolveFee } from "../src/lib/utils/fee.js"; +import { printJson, handleError } from "../src/lib/utils/cli.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const LAUNKR_API = "https://launkr.io/api"; + +// Every Launkr token is a byte-identical copy of restricted-token-template-v6, +// which declares `(define-fungible-token strategy-token)`. The SIP-010 asset +// name is therefore the same constant for every token, so sells can scope a +// real fungible post-condition instead of falling back to allow-all mode. +const STRATEGY_TOKEN_ASSET = "strategy-token"; + +// Singleton contract per network. Network selection follows the shared AIBTC +// `NETWORK` env var (default testnet) — the same source the wallet and every +// other skill use — so the singleton we target always matches the network the +// transaction is actually signed and broadcast on. The Hiro API host comes from +// the shared `getApiBaseUrl(network)` helper (single source of truth). +const SINGLETON: Record<"mainnet" | "testnet", string> = { + mainnet: "SP2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9Z367PM.lp-singleton-v6", + testnet: "ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE.lp-singleton-v6", +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Parse a Stacks principal string ("SP..." or "SP....contract") into a ClarityValue. */ +function parsePrincipalCV(principal: string): ClarityValue { + const parts = principal.split("."); + if (parts.length === 2) return contractPrincipalCV(parts[0], parts[1]); + return standardPrincipalCV(principal); +} + +/** + * Parse a typed arg descriptor from the Launkr /api/launch response into a ClarityValue. + * Supported types: principal, uint, string-ascii, string-utf8, optional-utf8, optional-ascii. + */ +function parseLaunkrArg(arg: { type: string; value: unknown }): ClarityValue { + switch (arg.type) { + case "principal": + return parsePrincipalCV(String(arg.value)); + case "uint": + return uintCV(BigInt(String(arg.value))); + case "string-ascii": + return stringAsciiCV(String(arg.value)); + case "string-utf8": + return stringUtf8CV(String(arg.value)); + case "optional-utf8": + return arg.value == null ? noneCV() : someCV(stringUtf8CV(String(arg.value))); + case "optional-ascii": + return arg.value == null ? noneCV() : someCV(stringAsciiCV(String(arg.value))); + default: + throw new Error(`Unsupported Launkr arg type: "${arg.type}"`); + } +} + +/** + * Call a read-only function on the Launkr singleton via the Hiro API. + * Returns the deserialized ClarityValue result or throws on error. + */ +async function callReadOnly( + hiroApi: string, + singletonId: string, + fnName: string, + args: ClarityValue[], + sender: string +): Promise<{ okay: boolean; result?: string; cause?: string }> { + const [contractAddr, contractName] = singletonId.split("."); + const url = `${hiroApi}/v2/contracts/call-read/${contractAddr}/${contractName}/${fnName}`; + + const hexArgs = args.map( + (cv) => `0x${Buffer.from(serializeCV(cv)).toString("hex")}` + ); + + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sender, arguments: hexArgs }), + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + throw new Error(`Hiro API error ${resp.status} calling ${fnName}: ${body}`); + } + + return resp.json() as Promise<{ okay: boolean; result?: string; cause?: string }>; +} + +/** + * Decode a hex-encoded Clarity value returned by Hiro's call-read endpoint. + * Returns a JS-friendly plain value via cvToValue, or the raw hex on failure. + */ +function decodeCV(hexResult: string): unknown { + try { + const bytes = Buffer.from(hexResult.replace(/^0x/, ""), "hex"); + const cv = deserializeCV(bytes); + return cvToValue(cv, true); // true = convert bigints to strings + } catch { + return hexResult; + } +} + +/** + * Poll the Hiro API until a transaction reaches a terminal status. + * Throws if the tx aborts/drops or if the timeout is exceeded. + */ +async function waitForConfirmation( + txid: string, + hiroApi: string, + timeoutMs = 300_000, + pollMs = 6_000 +): Promise { + const deadline = Date.now() + timeoutMs; + const url = `${hiroApi}/extended/v1/tx/${txid}`; + + process.stderr.write(`Waiting for tx ${txid} to confirm...\n`); + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollMs)); + + const resp = await fetch(url); + if (resp.status === 404) continue; // not indexed yet + if (!resp.ok) throw new Error(`Hiro API error ${resp.status} polling ${txid}`); + + const tx = (await resp.json()) as { tx_status: string }; + + if (tx.tx_status === "success") { + process.stderr.write(`Confirmed: ${txid}\n`); + return; + } + + const abortStatuses = [ + "abort_by_response", + "abort_by_post_condition", + "dropped_replace_by_fee", + "dropped_too_expensive", + "dropped_stale_garbage_collect", + "dropped_replace_across_fork", + "dropped_problematic", + ]; + if (abortStatuses.includes(tx.tx_status)) { + throw new Error(`Transaction failed with status: ${tx.tx_status}`); + } + + process.stderr.write(` status: ${tx.tx_status}, still waiting...\n`); + } + + throw new Error(`Timed out waiting for tx ${txid} after ${timeoutMs / 1000}s`); +} + +// --------------------------------------------------------------------------- +// Program +// --------------------------------------------------------------------------- + +const program = new Command(); + +program + .name("launkr") + .description( + "Launch and trade restricted SIP-010 tokens on Launkr — " + + "a protected token launcher and XYK AMM on the Stacks blockchain." + ) + .version("0.1.0"); + +// --------------------------------------------------------------------------- +// launch +// --------------------------------------------------------------------------- + +program + .command("launch") + .description( + "Launch a new token on Launkr: deploy the token contract (step 1), " + + "wait for confirmation, then create the AMM pool (step 2). " + + "Requires an unlocked wallet with STX for fees and optional seed." + ) + .requiredOption("--name ", "Token display name (max 32 chars)") + .requiredOption("--symbol ", "Token symbol (max 32 chars)") + .requiredOption( + "--supply ", + "Total supply in atomic units (min 100000000000000 = 100M @ 6 decimals)" + ) + .requiredOption( + "--mode ", + "Pool mode: 'bonding' (virtual reserves, 1% fee) or 'direct' (real STX seed, 5% fee)" + ) + .requiredOption( + "--fee-receiver
", + "STX address that receives 90% of swap fees" + ) + .option( + "--virtual-stx ", + "[bonding] Virtual STX reserve in uSTX (min 500000000 = 500 STX)" + ) + .option( + "--graduation-threshold ", + "[bonding] Real STX to collect before graduating (min 2000000000 = 2000 STX, max 10x virtual-stx)" + ) + .option( + "--stx-seed ", + "[direct] Real STX to seed the pool in uSTX (min 100000000 = 100 STX)" + ) + .option("--uri ", "Optional token metadata URI") + .option("--fee ", "Fee preset (low|medium|high) or micro-STX amount") + .action(async (opts) => { + try { + // Validate mode-specific required args locally before spending a round-trip. + if (opts.mode === "bonding") { + if (!opts.virtualStx || !opts.graduationThreshold) { + throw new Error( + "bonding mode requires --virtual-stx and --graduation-threshold" + ); + } + } else if (opts.mode === "direct") { + if (!opts.stxSeed) { + throw new Error("direct mode requires --stx-seed"); + } + } else { + throw new Error(`Unknown --mode "${opts.mode}" — use "bonding" or "direct"`); + } + + const network = NETWORK; + const singleton = SINGLETON[network]; + const hiroApi = getApiBaseUrl(network); + const account = await getAccount(); + + // ----------------------------------------------------------------------- + // Step 1 — Get the launch intent from the Launkr API + // ----------------------------------------------------------------------- + process.stderr.write(`Calling Launkr API to build launch intent...\n`); + + const launchBody: Record = { + network, + deployerAddress: account.address, + name: opts.name, + symbol: opts.symbol, + supply: opts.supply, + mode: opts.mode, + feeReceiver: opts.feeReceiver, + ...(opts.uri && { uri: opts.uri }), + ...(opts.virtualStx && { virtualStx: opts.virtualStx }), + ...(opts.graduationThreshold && { graduationThreshold: opts.graduationThreshold }), + ...(opts.stxSeed && { stxSeed: opts.stxSeed }), + }; + + const launchResp = await fetch(`${LAUNKR_API}/launch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(launchBody), + }); + + if (!launchResp.ok) { + const errBody = await launchResp.json().catch(() => ({ error: "unknown" })) as { + error: string; + }; + throw new Error(`Launkr API error ${launchResp.status}: ${errBody.error}`); + } + + type LaunkrStep = { + step: number; + kind: string; + contractName?: string; + clarityCode?: string; + clarityVersion?: number; + functionName?: string; + functionArgs?: Array<{ type: string; value: unknown }>; + postConditionMode?: string; + postConditions?: unknown[]; + note?: string; + }; + + const intent = (await launchResp.json()) as { + tokenPrincipal: string; + singletonId: string; + steps: LaunkrStep[]; + }; + + const deployStep = intent.steps[0]; + const poolStep = intent.steps[1]; + + if (!deployStep?.clarityCode || !deployStep.contractName) { + throw new Error("Launkr API returned an unexpected intent shape (missing step 1)"); + } + if (!poolStep?.functionName || !poolStep.functionArgs) { + throw new Error("Launkr API returned an unexpected intent shape (missing step 2)"); + } + + // ----------------------------------------------------------------------- + // Step 2 — Deploy the token contract (byte-for-byte copy of template) + // ----------------------------------------------------------------------- + process.stderr.write( + `Deploying token contract "${deployStep.contractName}" on ${network}...\n` + ); + + const deployFee = await resolveFee(opts.fee, network, "smart_contract"); + const deployResult = await deployContract(account, { + contractName: deployStep.contractName, + codeBody: deployStep.clarityCode, + ...(deployFee !== undefined && { fee: deployFee }), + // Honor the clarity version the intent asks for (template needs Clarity 4). + ...(deployStep.clarityVersion !== undefined && { + clarityVersion: deployStep.clarityVersion as ClarityVersion, + }), + }); + + process.stderr.write(`Deploy tx broadcast: ${deployResult.txid}\n`); + + // ----------------------------------------------------------------------- + // Step 3 — Wait for deploy to confirm + // ----------------------------------------------------------------------- + await waitForConfirmation(deployResult.txid, hiroApi); + + // ----------------------------------------------------------------------- + // Step 4 — Create the pool + // ----------------------------------------------------------------------- + const clarityArgs = poolStep.functionArgs.map(parseLaunkrArg); + const poolFee = await resolveFee(opts.fee, network, "contract_call"); + const [singletonAddr, singletonName] = singleton.split("."); + + // Direct mode: post-condition guards the STX seed pulled from the caller. + // Bonding mode: no STX is pulled at creation — empty post-conditions. + const postConditions = + opts.mode === "direct" && opts.stxSeed + ? [createStxPostCondition(account.address, "eq", BigInt(opts.stxSeed))] + : []; + + process.stderr.write(`Creating ${opts.mode} pool on ${singleton}...\n`); + + const poolResult = await callContract(account, { + contractAddress: singletonAddr, + contractName: singletonName, + functionName: poolStep.functionName, + functionArgs: clarityArgs, + postConditionMode: PostConditionMode.Deny, + ...(postConditions.length > 0 && { postConditions }), + ...(poolFee !== undefined && { fee: poolFee }), + }); + + printJson({ + success: true, + tokenPrincipal: intent.tokenPrincipal, + deployTxid: deployResult.txid, + poolTxid: poolResult.txid, + network, + explorerUrl: getExplorerTxUrl(poolResult.txid, network), + launkrUrl: `https://launkr.io/token/${intent.tokenPrincipal}`, + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// get-pool +// --------------------------------------------------------------------------- + +program + .command("get-pool") + .description( + "Get pool state for a token (reserves, mode, graduation progress, fee-receiver). " + + "No wallet required." + ) + .requiredOption( + "--token ", + "Full token principal in ADDRESS.contract-name format" + ) + .action(async (opts) => { + try { + const network = NETWORK; + const singleton = SINGLETON[network]; + const hiroApi = getApiBaseUrl(network); + + let sender: string; + try { + sender = await getWalletAddress(); + } catch { + // Fallback — any valid address works for read-only calls + sender = + network === "mainnet" + ? "SP000000000000000000002Q6VF78" + : "ST000000000000000000002AMW42H"; + } + + const result = await callReadOnly( + hiroApi, + singleton, + "get-pool", + [parsePrincipalCV(opts.token)], + sender + ); + + if (!result.okay) { + throw new Error(`get-pool failed: ${result.cause ?? result.result}`); + } + + const decoded = decodeCV(result.result ?? ""); + + // get-pool returns (optional {tuple}); cvToValue wraps `some` as + // { value: {tuple} } and `none` as null — unwrap the optional here. + const pool = + decoded != null && + typeof decoded === "object" && + "value" in (decoded as Record) + ? (decoded as Record)["value"] + : decoded; + + if (pool == null || pool === false) { + printJson({ found: false, token: opts.token, network }); + return; + } + + // Map mode uint string → human-readable label + const modeMap: Record = { + "0": "direct", + "1": "bonding", + "2": "graduated", + }; + + const p = pool as Record; + const rawMode = String(p["mode"] ?? ""); + printJson({ + found: true, + token: opts.token, + network, + mode: modeMap[rawMode] ?? rawMode, + active: p["active"], + stxReserve: p["stx-reserve"], + tokenReserve: p["token-reserve"], + virtualStx: p["virtual-stx"], + virtualToken: p["virtual-token"], + graduationThreshold: p["graduation-threshold"], + bondedStxCollected: p["bonded-stx-collected"], + bondedTokensSold: p["bonded-tokens-sold"], + feeReceiver: p["fee-receiver"], + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// quote-buy +// --------------------------------------------------------------------------- + +program + .command("quote-buy") + .description( + "Simulate a buy and return the expected tokens out (net of fees). " + + "No wallet required. Use the result to set --min-tokens-out in swap-buy." + ) + .requiredOption("--token ", "Full token principal") + .requiredOption("--stx-in ", "uSTX to spend") + .action(async (opts) => { + try { + const network = NETWORK; + const singleton = SINGLETON[network]; + const hiroApi = getApiBaseUrl(network); + + let sender: string; + try { + sender = await getWalletAddress(); + } catch { + sender = + network === "mainnet" + ? "SP000000000000000000002Q6VF78" + : "ST000000000000000000002AMW42H"; + } + + const result = await callReadOnly( + hiroApi, + singleton, + "quote-buy", + [parsePrincipalCV(opts.token), uintCV(BigInt(opts.stxIn))], + sender + ); + + if (!result.okay) { + throw new Error(`quote-buy failed: ${result.cause ?? result.result}`); + } + + // Result shape: (ok (some uN)) → decoded as { value: { value: "N" } } + // or (ok none) → decoded as { value: null } + const decoded = decodeCV(result.result ?? ""); + const inner = + decoded != null && + typeof decoded === "object" && + "value" in (decoded as Record) + ? (decoded as Record)["value"] + : decoded; + + const tokensOut = + inner != null && typeof inner === "object" && "value" in (inner as Record) + ? String((inner as Record)["value"]) + : inner != null + ? String(inner) + : null; + + printJson({ + token: opts.token, + stxIn: opts.stxIn, + tokensOut, + network, + note: + tokensOut === null + ? "Pool not found or stx-in is zero" + : `Use ${tokensOut} (minus slippage tolerance) as --min-tokens-out in swap-buy`, + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// quote-sell +// --------------------------------------------------------------------------- + +program + .command("quote-sell") + .description( + "Simulate a sell and return the expected STX out (net of fees). " + + "No wallet required. Use the result to set --min-stx-out in swap-sell." + ) + .requiredOption("--token ", "Full token principal") + .requiredOption("--tokens-in ", "Atomic token units to sell") + .action(async (opts) => { + try { + const network = NETWORK; + const singleton = SINGLETON[network]; + const hiroApi = getApiBaseUrl(network); + + let sender: string; + try { + sender = await getWalletAddress(); + } catch { + sender = + network === "mainnet" + ? "SP000000000000000000002Q6VF78" + : "ST000000000000000000002AMW42H"; + } + + const result = await callReadOnly( + hiroApi, + singleton, + "quote-sell", + [parsePrincipalCV(opts.token), uintCV(BigInt(opts.tokensIn))], + sender + ); + + if (!result.okay) { + throw new Error(`quote-sell failed: ${result.cause ?? result.result}`); + } + + const decoded = decodeCV(result.result ?? ""); + const inner = + decoded != null && + typeof decoded === "object" && + "value" in (decoded as Record) + ? (decoded as Record)["value"] + : decoded; + + const stxOut = + inner != null && typeof inner === "object" && "value" in (inner as Record) + ? String((inner as Record)["value"]) + : inner != null + ? String(inner) + : null; + + printJson({ + token: opts.token, + tokensIn: opts.tokensIn, + stxOut, + network, + note: + stxOut === null + ? "Pool not found or tokens-in is zero" + : `Use ${stxOut} (minus slippage tolerance) as --min-stx-out in swap-sell`, + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// swap-buy +// --------------------------------------------------------------------------- + +program + .command("swap-buy") + .description( + "Buy tokens with STX via swap-exact-stx-for-tokens on the Launkr singleton. " + + "Run quote-buy first and apply a slippage tolerance (1–2%) to --min-tokens-out. " + + "Requires an unlocked wallet." + ) + .requiredOption("--token ", "Full token principal (ADDRESS.contract-name)") + .requiredOption("--stx-in ", "uSTX to spend") + .requiredOption( + "--min-tokens-out ", + "Minimum tokens to receive — slippage guard (use quote-buy first)" + ) + .option( + "--deadline ", + "Max Stacks block height (default: 4294967295 = no deadline)", + "4294967295" + ) + .option( + "--recipient
", + "Address to receive tokens (default: wallet address)" + ) .option("--fee ", "Fee preset (low|medium|high) or micro-STX amount") + .action(async (opts) => { + try { + const network = NETWORK; + const singleton = SINGLETON[network]; + const account = await getAccount(); + const recipient = opts.recipient ?? account.address; + const [singletonAddr, singletonName] = singleton.split("."); + const resolvedFee = await resolveFee(opts.fee, network, "contract_call"); + + const result = await callContract(account, { + contractAddress: singletonAddr, + contractName: singletonName, + functionName: "swap-exact-stx-for-tokens", + functionArgs: [ + parsePrincipalCV(opts.token), + uintCV(BigInt(opts.stxIn)), + uintCV(BigInt(opts.minTokensOut)), + uintCV(BigInt(opts.deadline)), + parsePrincipalCV(recipient), + ], + postConditionMode: PostConditionMode.Deny, + // Guard: caller sends exactly stxIn uSTX (no more, no less). + postConditions: [ + createStxPostCondition(account.address, "eq", BigInt(opts.stxIn)), + ], + ...(resolvedFee !== undefined && { fee: resolvedFee }), + }); + + printJson({ + success: true, + txid: result.txid, + token: opts.token, + stxIn: opts.stxIn, + minTokensOut: opts.minTokensOut, + recipient, + network, + explorerUrl: getExplorerTxUrl(result.txid, network), + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// swap-sell +// --------------------------------------------------------------------------- + +program + .command("swap-sell") + .description( + "Sell tokens for STX via swap-exact-tokens-for-stx on the Launkr singleton. " + + "Run quote-sell first and apply a slippage tolerance (1–2%) to --min-stx-out. " + + "Requires an unlocked wallet. Scopes a fungible post-condition on the tokens " + + "sold (asset name `strategy-token`) on top of the on-chain min-stx-out guard." + ) + .requiredOption("--token ", "Full token principal") + .requiredOption("--tokens-in ", "Atomic token units to sell") + .requiredOption( + "--min-stx-out ", + "Minimum STX to receive — slippage guard (use quote-sell first)" + ) + .option("--deadline ", "Max Stacks block height (default: no deadline)", "4294967295") + .option("--recipient
", "Address to receive STX (default: wallet address)") .option("--fee ", "Fee preset (low|medium|high) or micro-STX amount") + .action(async (opts) => { + try { + const network = NETWORK; + const singleton = SINGLETON[network]; + const account = await getAccount(); + const recipient = opts.recipient ?? account.address; + const [singletonAddr, singletonName] = singleton.split("."); + const resolvedFee = await resolveFee(opts.fee, network, "contract_call"); + + const result = await callContract(account, { + contractAddress: singletonAddr, + contractName: singletonName, + functionName: "swap-exact-tokens-for-stx", + functionArgs: [ + parsePrincipalCV(opts.token), + uintCV(BigInt(opts.tokensIn)), + uintCV(BigInt(opts.minStxOut)), + uintCV(BigInt(opts.deadline)), + parsePrincipalCV(recipient), + ], + postConditionMode: PostConditionMode.Deny, + // Guard: caller sends exactly tokensIn of the restricted token. Its asset + // name is always `strategy-token` (byte-frozen template), so we can scope + // a real FT post-condition. The STX the singleton pays back is covered by + // its own Clarity-4 as-contract? allowance — no tx-level PC needed for it. + postConditions: [ + createFungiblePostCondition( + account.address, + opts.token, + STRATEGY_TOKEN_ASSET, + "eq", + BigInt(opts.tokensIn) + ), + ], + ...(resolvedFee !== undefined && { fee: resolvedFee }), + }); + + printJson({ + success: true, + txid: result.txid, + token: opts.token, + tokensIn: opts.tokensIn, + minStxOut: opts.minStxOut, + recipient, + network, + explorerUrl: getExplorerTxUrl(result.txid, network), + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// Parse +// --------------------------------------------------------------------------- + +program.parse(process.argv); diff --git a/skills.json b/skills.json index 61c143f..837806f 100644 --- a/skills.json +++ b/skills.json @@ -1534,6 +1534,36 @@ "jingswap_v2_cancel_cycle" ] }, + { + "name": "launkr", + "description": "Launch and trade restricted SIP-010 tokens on Launkr — a protected token launcher and XYK AMM on Stacks. Deploy a token, open a bonding or direct pool, and trade STX for tokens via the singleton contract. Works on both mainnet and testnet.", + "entry": "launkr/launkr.ts", + "arguments": [ + "launch", + "get-pool", + "quote-buy", + "quote-sell", + "swap-buy", + "swap-sell" + ], + "requires": [ + "wallet" + ], + "tags": [ + "l2", + "defi", + "write", + "requires-funds" + ], + "userInvocable": false, + "author": "rather-labs", + "authorAgent": "Launkr by Rather Labs", + "mcpTools": [ + "deploy_contract", + "call_contract", + "call_read_only_function" + ] + }, { "name": "lunarcrush", "description": "Pay-per-call access to LunarCrush social and market intelligence (Galaxy Score, AltRank, market cap rank, price, 24h change) via x402 on Stacks. USD-pegged pricing recomputed hourly from live STX/USD. Mainnet endpoint live; testnet supported.", diff --git a/src/lib/transactions/builder.ts b/src/lib/transactions/builder.ts index 1836bbc..4b7d158 100644 --- a/src/lib/transactions/builder.ts +++ b/src/lib/transactions/builder.ts @@ -4,6 +4,7 @@ import { makeContractDeploy, broadcastTransaction, ClarityValue, + ClarityVersion, PostConditionMode, PostCondition, } from "@stacks/transactions"; @@ -69,6 +70,8 @@ export interface ContractDeployOptions { fee?: bigint; /** Optional explicit nonce. If omitted, auto-fetched from network. */ nonce?: bigint; + /** Optional Clarity version for the deploy. If omitted, Stacks.js picks the default. */ + clarityVersion?: ClarityVersion; } /** @@ -168,6 +171,7 @@ export async function deployContract( network: networkName, ...(options.fee !== undefined && { fee: options.fee }), ...(options.nonce !== undefined && { nonce: options.nonce }), + ...(options.clarityVersion !== undefined && { clarityVersion: options.clarityVersion }), }); const broadcastResponse = await broadcastTransaction({