diff --git a/README.md b/README.md index a30d9d43..0038db0c 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 00000000..45cd356d --- /dev/null +++ b/launkr/AGENT.md @@ -0,0 +1,178 @@ +--- +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. `launkr.ts` follows the + network of whichever wallet is currently loaded (set by the `NETWORK` + env var *at wallet-creation time*, not per command) — there is + deliberately no per-command `--network` override for `launch`, + `swap-buy`, or `swap-sell`, because the actual broadcast target is + always the loaded wallet's network regardless of any flag, and a flag + that looked like it controlled that but didn't was a real, confirmed + bug in an earlier version of this skill. Check which wallet/network is + active before running a write command, don't assume. (`get-pool`, + `quote-buy`, `quote-sell` are pure reads with no wallet coupling, so + they do still take an independent `--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. **Omitting `--uri` correctly results in an on-chain `none`.** An earlier + version of this skill worked around a `BadFunctionArgument` rejection by + sending `Some("")` instead — that turned out to be specific to a + different runtime environment, not a real Stacks/Clarity constraint, and + was reverted once confirmed. See `SKILL.md` for the verification detail + if you're touching `parseLaunkrArg` again — re-verify on-chain before + changing this back, don't reason about it from the code alone (that's + exactly how this got it wrong the first time). +4. `--virtual-stx`/`--graduation-threshold` (bonding) and `--stx-seed` + (direct) are required per mode — `launch` and `create-pool` both fail + fast with a clear message if the wrong ones are missing, rather than + deploying the token first and only discovering the gap when pool + creation aborts. (That was a real bug: `--stx-seed` used to be optional, + so a `--mode direct` run with no seed proceeded through the whole + deploy before failing.) For **direct mode**, separately confirm you + actually hold ≥ `stxSeed` uSTX before attempting the call — the required + flag only means the value was supplied, not that you can afford it. +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). +6. **Trust but verify the Launkr API's response** — this is the part of + `launch` most worth re-reading if you're extending it, since it's been + wrong twice already in ways that only surfaced on-chain. `launkr.ts` + checks, before spending any gas: the deploy step's `clarityCode` + byte-matches the real on-chain template; the pool-creation step calls + the function that matches your `--mode` (`create-pool-bonding` vs + `create-pool-direct` — a response could otherwise call the wrong one + while every arg still looked fine); the pool-creation args are exactly + the length the mode requires (not just "at least" — a short bonding + response can read `fee-receiver` as `graduation-threshold`); and the + `token`/`name`/`symbol`/`supply`/`fee-receiver`/curve-parameter values + in those args match what was requested. Don't remove or narrow any of + these — each one closes a gap a previous version of this file actually + had. +7. **`launch` is two transactions with no atomicity between them, but there + is a recovery path.** If pool creation (step 2) fails or the process is + interrupted after the token deploys, don't re-run `launch` — it deploys + a *second* token. Use `create-pool --token ` + with the same params instead; `launch` itself prints this exact + instruction after a successful deploy. + +## 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. **Every principal that moves an asset in the swap needs a post-condition + under `Deny` mode, not just you.** A buy has the singleton sending back + both the token *and* STX (two fee legs); a sell has the singleton paying + out STX (proceeds + fees). `launkr.ts` covers all of it — your own leg + tightly (`eq`), the singleton's fee-paying leg loosely (`gte 0`, since + that's the contract's own fee math, not something worth asserting an + exact bound on), and the singleton's payout to you as the real slippage + guard (`gte` your minimum). One post-condition per (principal, asset) + covers everything that principal sends of that asset in the whole + transaction — you don't need a separate condition per individual + transfer. Don't strip any of these down to "just the caller" again — + an earlier version did exactly that and aborted every single swap with + `abort_by_post_condition`, verified on both testnet and mainnet. +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. +- `abort_by_post_condition` specifically means some asset movement in the + transaction wasn't covered by a post-condition — check the node's + `vm_error` for which principal/asset it flagged rather than guessing. +- 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. + If you do need to fix it after the fact, `set-fee-receiver` (called by + the *current* receiver) followed by `accept-fee-receiver` (called by the + new one) is the correction path — it's a real two-step on-chain transfer, + exposed by this skill, not just a theoretical escape hatch. +- **Correction (biwasxyz review round 2): the line that used to be here was + wrong, not just imprecise.** It said tokens can never be transferred + outside the singleton and that selling back is the only way out. From + `is-recipient-allowed` in the deployed `restricted-token-template-v6`: + a transfer to an ordinary wallet (a standard principal) is **always** + allowed — `(is-none (get name ok-parts)) → true`, unconditionally. The + gate only applies to **contract** recipients (checked against + `approved-principals`/`approved-code-hashes`, which start out containing + only the singleton). So the real restriction is on *composability* — you + can't hand the token to another DEX, use it as collateral, or plug it + into another protocol unless that contract gets allowlisted — not on + moving it at all. Selling via `swap-sell` is how you convert it back to + STX, but sending it to a friend's wallet, another EOA you control, or an + exchange deposit address (if that's a standard principal, as most are) + works today, unconditionally. Also not permanent: the allowlist admin + (hardcoded in the template at deploy time, itself transferable via + `set-pending-allowlist-admin`/`accept-allowlist-admin`) can add or remove + approved principals/code hashes at any time. Tell a user deciding whether + to hold or launch the real shape of the restriction, not the wrong one. +- 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. (This instruction was previously + unfollowable: `get-pool` had a decode bug that returned every field as + `undefined` or the literal string `"[object Object]"`, `active` included + — an agent checking `active` got a falsy value regardless of the pool's + real state. Fixed by decoding the full Clarity-value tree instead of + assuming a fixed unwrap depth; verified against a real pool.) +- 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. +- Don't assume documentation and code agree without checking — if you're + editing either `SKILL.md`/`AGENT.md` or `launkr.ts`, update both together + and re-verify end-to-end **on-chain, not just by reading the code** before + pushing. Three separate real bugs made it past review this way already: + docs describing `none` while the code sent `Some("")`; `Deny`-mode + post-conditions that looked correct on paper but had never actually been + broadcast; and a decode helper (`get-pool`) that looked correct on paper + and had *also* never actually been run against a real response. All three + only surfaced once someone checked the chain — reading the code carefully + was not enough in any of the three cases, and paraphrasing an unverified + claim in a PR comment (which briefly happened here too, over the + `noneCV()` root-cause writeup) is its own version of the same mistake. diff --git a/launkr/SKILL.md b/launkr/SKILL.md new file mode 100644 index 00000000..01834ddb --- /dev/null +++ b/launkr/SKILL.md @@ -0,0 +1,393 @@ +--- +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 | create-pool | get-pool | quote-buy | quote-sell | swap-buy | swap-sell | set-fee-receiver | accept-fee-receiver" + 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 — a protected token +launcher and AMM on the Stacks blockchain. Contracts are public and +permissionless — any agent with a Stacks wallet can call them directly. No +intermediary, no custody, no fee to any third party beyond Launkr's own +protocol fee (see below). + +**What Launkr is:** A singleton XYK AMM that hosts N pools. Each pool trades +STX against a *restricted* SIP-010 token. "Restricted" means composability, +not custody: transfers to an ordinary wallet always succeed; only sending +to a *contract* is gated (allowlisted principals/code-hashes, initially +just the singleton). This guarantees fee capture on every *swap* — trades +must go through the singleton — without freezing the token in your wallet. +See `AGENT.md`'s "What NOT to do" for the precise on-chain logic and why +an earlier draft of these docs described this restriction incorrectly. + +**Two pool modes:** +- **Bonding** (`create-pool-bonding`) — Starts with virtual reserves. No STX + seed required. Fee: 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). Fee: 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 the Hiro API — never modify it, even +whitespace. Any change breaks the singleton's hash-based allowlist check +(`ERR_TOKEN_NOT_OURS u201`). + +--- + +## Protocol Info + +Get contract IDs, floors, and fee schedule (call this first — addresses can +change between deployments, always fetch fresh): + +``` +GET https://launkr.io/api/protocol?network=mainnet +GET https://launkr.io/api/protocol?network=testnet +``` + +**Mainnet contracts** (redeployed 2026-07-16, dropping an earlier `-mn-demo` +staging suffix — verified live on-chain, see worked examples below): +- 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` + +`get-pool`, `quote-buy`, and `quote-sell` are pure reads and take an +explicit `--network mainnet|testnet` flag. `launch`, `swap-buy`, and +`swap-sell` do **not** — they always follow whichever wallet is currently +loaded (set by the `NETWORK` env var at wallet-creation time), because +that's what actually determines the broadcast target no matter what a flag +says. An earlier version of this skill had a `--network` flag on every +command, including the three that sign transactions — it silently had no +effect on where the transaction was actually sent, since `callContract`/ +`deployContract` read the network from the account, not from any +parameter. Confirm which wallet/network is active before running a write +command rather than expecting a flag to control it. + +## Fees + +| Mode | Treasury | Protocol | Total | +|---|---|---|---| +| Bonding | 0.90% | 0.10% | 1.00% | +| Direct / Graduated | 4.50% | 0.50% | 5.00% | +| swap-and-burn | — | — | 0% (graduated pools only) | + +## Protocol Floors + +| 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 | + +--- + +## 1. Launch a token + +Two sequential on-chain transactions. `POST /api/launch` returns unsigned +Clarity source + call params for both — you sign and broadcast each yourself +with your own key. **Do not send step 2 until step 1 confirms on-chain.** + +``` +POST https://launkr.io/api/launch +{ + "network": "mainnet", + "deployerAddress": "", + "name": "My Agent Token", + "symbol": "MAT", + "supply": "1000000000000000", // atomic units, 6 decimals — e.g. this = 1B tokens + "mode": "bonding", + "virtualStx": "500000000", // uSTX, min 500000000 (500 STX) + "graduationThreshold": "2000000000",// uSTX, min 2000000000 (2000 STX) + "feeReceiver": "" +} +``` + +> **Gotcha:** `virtualStx` / `graduationThreshold` (bonding) or `stxSeed` +> (direct) must be **top-level** fields in this POST body, NOT nested under +> a `bondingMode`/`directMode` object — even though the `GET /api/protocol` +> response's own example schema shows them nested. Nesting them silently +> fails validation with `"virtualStx must be >= 500000000 uSTX"` even when +> the value is valid. `launkr.ts` already builds the flat body correctly. + +Response gives you two steps: + +1. **contract-deploy** — the token's Clarity source (byte-frozen, don't edit + it — see the hash-gate warning above). +2. **contract-call** `create-pool-bonding` (or `create-pool-direct`) on the + singleton — only send after step 1 confirms. Poll + `GET https://api.hiro.so/extended/v1/tx/{txid}` until `tx_status: "success"`. + +`create-pool-bonding` args, in order: +`token` (trait_reference/principal) · `name` (string-ascii 32) · `symbol` +(string-ascii 32) · `decimals` (uint) · `supply` (uint) · `uri` (optional +string-utf8 256) · `virtual-stx` (uint) · `graduation-threshold` (uint) · +`fee-receiver` (principal) + +`create-pool-direct` is the same shape with `stx-seed` (uint) replacing +`virtual-stx`/`graduation-threshold`, and **requires a matching STX +post-condition** (`eq`, amount = stxSeed) since it pulls real STX from the +caller at creation. Bonding mode pulls no STX at creation — its +post-conditions array is empty. + +> **Resolved (2026-08-05, corrected 2026-08-17):** an earlier version of +> `launkr.ts` sent an explicit `Some("")` instead of `none` for an omitted +> `uri`, working around a `BadFunctionArgument` broadcast rejection. That +> rejection turned out to be specific to a *different* environment (the +> published `@aibtc/mcp-server` npm package's own dependency resolution) +> rather than a Stacks/Clarity issue — a bare `noneCV()` broadcasts and +> confirms fine against this repo's own pinned `@stacks/transactions@7.3.1`. +> (A prior version of this note cited two testnet txids for this that +> don't exist on-chain — written before the test was actually run, an +> error rather than a stale reference. Verified for real afterward: mainnet +> txid `29b7e58d636d2be118ca658707220e3f5ff19100fbb264f5aeb00c765202e390`, +> `(ok true)`, calling `set-token-uri` with a bare `noneCV()` signed with +> this exact pinned dependency version.) `parseLaunkrArg` sends a proper +> `none` again — a token launched without `--uri` correctly has `none` +> on-chain, not an empty string. + +## 2. Quote a trade (free, read-only, no gas) + +``` +POST https://api.hiro.so/v2/contracts/call-read///quote-buy +{ "sender": "", "arguments": ["", ""] } +``` + +Same shape for `quote-sell` (tokens-in → stx-out) and `quote-swap-and-burn` +(graduated pools only). Returns `none` if the pool doesn't exist or the +input is zero — always check for `none` before using the result. + +Also useful: `get-pool` (full pool state: reserves, mode, graduation +progress, fee-receiver) and `is-paused` (protocol-wide kill switch). + +## 3. Swap (buy / sell) + +Direct contract calls on the singleton — no API round-trip needed once you +have a quote. + +**Buy:** `swap-exact-stx-for-tokens(token, stx-in, min-tokens-out, deadline, recipient)` + +**Sell:** `swap-exact-tokens-for-stx(token, tokens-in, min-stx-out, deadline, recipient)` +— every Launkr token uses the **identical internal FT asset name +`strategy-token`**, regardless of display name/symbol (verified against the +deployed byte-frozen template source, both networks — only the contract +address varies). + +> **Gotcha — fixed in `launkr.ts`, verified on-chain on both testnet and +> mainnet:** under `PostConditionMode.Deny`, **every principal that moves +> an asset in the transaction needs a post-condition, not just the +> caller.** A buy has the singleton sending back both the FT payout *and* +> STX (the treasury + protocol fee legs); a sell has the singleton paying +> out STX (proceeds + both fee legs). Post-conditioning only the caller's +> own leg — which looks like the obviously-correct, safe thing to do — +> aborts with `abort_by_post_condition` every time, because the +> uncovered singleton-originated transfers get flagged. One +> post-condition per `(principal, asset)` pair covers the aggregate amount +> that principal sends of that asset across the whole transaction, so this +> doesn't need one condition per individual fee leg. The complete correct +> set: +> - **Buy** — caller `eq stx-in` (uSTX), singleton `gte 0` (uSTX, covers +> the fee legs — not meaningfully boundable since that's the contract's +> own fee math, not caller input), singleton `gte min-tokens-out` (FT, +> `strategy-token` — this is the real slippage guard). +> - **Sell** — caller `eq tokens-in` (FT, `strategy-token`), singleton +> `gte min-stx-out` (uSTX — covers proceeds + both fee legs in one +> aggregate check, and is itself the real slippage guard). + +Always call `quote-buy`/`quote-sell` first and set `min-tokens-out` / +`min-stx-out` a few % under the quote as a slippage guard. Use +`deadline: 0xffffffff` (4294967295) if you don't need a block-height cutoff. + +## 4. Recovery, fee-receiver transfer, and other operations + +**`create-pool`** — recovery path for when `launch` deployed the token but +the pool-creation step failed or was interrupted (`launch` is two separate +transactions with no atomicity between them). Takes `--token +` plus the same params `launch` would have +used, and runs only the pool-creation call — built directly from the +documented `create-pool-bonding`/`create-pool-direct` signature above +rather than round-tripping through `/api/launch` again (nothing in that +response for this step isn't already fully determined by your own input). +Re-running `launch` itself after a failed step 2 would deploy a *second* +token, not resume — use `create-pool` instead. + +**`set-fee-receiver` / `accept-fee-receiver`** — the singleton's two-step +fee-receiver transfer (`set-pending-fee-receiver` proposed by the current +receiver, `accept-fee-receiver` confirmed by the new one), exposed as CLI +subcommands. The fee-receiver collects 90% of swap volume permanently +once a pool is created — this is the only correction path if it was set +wrong. + +**Deploy verification:** `launch` now fetches the real template source +on-chain (`GET /v2/contracts/source/...`) and byte-compares it against the +API's `clarityCode` *before* deploying, rather than trusting the API +response and finding out only after the deploy fee is spent that the +singleton would have rejected it (`ERR_TOKEN_NOT_OURS`) anyway. + +**Pool-arg verification:** `validatePoolStepMatchesRequest` (see `launch`) +now also checks `virtual-stx`/`graduation-threshold` (bonding) or +`stx-seed` (direct) against what was requested, not just +name/symbol/supply/fee-receiver — those curve parameters are just as +capable of coming back wrong from the API and are what the entire price +curve is built from. + +**Does graduating a pool unlock token transfers?** Not applicable the way +this question originally assumed — see the correction below. Graduating +only changes the pool's `mode` field (bonding fee 1% → graduated fee 5%, +and enables `swap-and-burn`); it never touches the token contract's +allowlist, before or after graduation. + +**Correction (2026-08-17):** an earlier version of this doc (and +`AGENT.md`) claimed tokens can *only* ever move by selling back through +the singleton, with transfers "restricted to the singleton permanently." +That's wrong. From `is-recipient-allowed` in the deployed +`restricted-token-template-v6`: `(is-none (get name ok-parts)) → true` — +a transfer to a **standard principal** (an ordinary wallet) is always +allowed, unconditionally. The allowlist (`approved-principals`/ +`approved-code-hashes`, seeded with only the singleton) gates **contract** +recipients only. So the real restriction is on composability — you can't +plug the token into another DEX, use it as collateral, or hand it to any +other contract unless the allowlist-admin adds it — not on moving it at +all; sending to another wallet you or someone else controls works today, +same as any SIP-010 token. It's also not necessarily permanent: the +allowlist admin (hardcoded in the template at deploy time, itself +transferable via `set-pending-allowlist-admin`/`accept-allowlist-admin`) +can add or remove approved principals/code hashes at any time. + +**Could this skill build pool-creation args on-chain and skip +`/api/launch` for step 2 entirely?** Yes — `create-pool` already does +this. Extending that to skip the API for step 1 too (fetch the template +on-chain, deploy under a locally-chosen contract name, never call +`/api/launch` at all) is also possible in principle, since nothing in that +response is undeterminable from on-chain data plus your own input. +Deliberately not done here: launches submitted through `/api/launch` are +how Launkr's own backend currently learns about new tokens for its own +tracking, separate from the on-chain event indexing `launkr.io`'s frontend +already does independently. Whether to give that up for a smaller trust +surface is a product decision for the Launkr team, not something to +change unilaterally in a skill PR. + +**Config now actually fetched live.** `AGENT.md` has always said to fetch +`GET /api/protocol` fresh every session rather than hardcode an address — +but nothing in `launkr.ts` called it; every command read the `-v6` +addresses baked into the script at the time it was written. Fixed: +`fetchProtocolConfig` now calls the live endpoint at the start of every +command that needs the singleton or template address, falling back to the +baked-in addresses (with a warning) only if the request fails. This +contract already redeployed once (2026-07-16); a second redeploy would +previously have made every write target a retired contract and every read +report `found: false`, silently. + +**`get-pool` decode bug, fixed.** A single `.value` unwrap isn't enough for +a tuple response — every field *inside* the tuple is its own `{type, +value}` node one level further down. The old code read `mode`/`active`/ +reserves directly off the once-unwrapped result and got back objects +(`String(...)` → `"[object Object]"`) or `undefined` for every field, +while still reporting `found: true`. Verified directly against a real +mainnet pool: the old code produced `mode: "[object Object]"`; the fix +(`unwrapCV`, a small recursive tree-flattener) produces `mode: "bonding"`, +`active: true`, and correct numeric strings for every reserve field, from +the same response. `quote-buy`/`quote-sell` didn't have this bug (their +result shape happens to bottom out one level shallower) but now share the +same helper instead of near-duplicate manual unwrap logic. + +**`launch`/`create-pool` now require the mode-appropriate curve flags.** +`--virtual-stx`/`--graduation-threshold` (bonding) and `--stx-seed` +(direct) used to be plain optional flags. A `--mode direct` launch with no +`--stx-seed` used to proceed through the entire token deploy — spending +that fee — before failing at pool creation with `abort_by_post_condition` +(the post-condition guarding the seed can't be built from `undefined`). +Both commands now validate this before doing anything on-chain. As a +consequence, `validatePoolStepMatchesRequest`'s curve-parameter checks +(added when the checker was first extended to cover them) are now always +exercised — they used to silently no-op whenever the corresponding flag +was omitted, which was the default, most common case. + +**`validatePoolStepMatchesRequest` now checks the pool-creation function +name, the exact arg count per mode, and the token principal, not just the +argument values.** Three more gaps: the API's chosen `functionName` was +invoked verbatim with no check that it matched the locally-validated +`--mode` (a `create-pool-direct` response under `--mode bonding` would +have passed every other check and broadcast a call that pulls STX with no +post-condition); the arg-count check accepted `>= 8`, so an 8-arg response +under bonding mode (which needs 9) had `graduation-threshold` and +`fee-receiver` silently read from the same slot; and `args[0]`, the token +the pool is even for, was never compared against the token that was +actually just deployed. All three are checked now. + +**`create-pool` reads `decimals` from the already-deployed token contract** +instead of hardcoding `6` — `launch` takes decimals from whatever the API +response used, so a hardcoded value in the recovery path could silently +diverge and create a differently-configured pool than `launch` would have. +Reading it back from the live contract via `get-decimals` can't drift from +what's actually on-chain, by construction. + +**`launch` now waits for the pool-creation transaction to confirm**, not +just the deploy — it used to print `success: true` right after +*broadcasting* the pool-creation call, so a caller had no way to +distinguish "pool created" from "pool creation is still pending" from +"pool creation aborted on-chain" from the JSON output alone. + +## Error codes + +| Code | Meaning | +|---|---| +| u200 | ERR_POOL_EXISTS — pool already exists for this token | +| u201 | ERR_TOKEN_NOT_OURS — token source hash doesn't match APPROVED_TOKEN_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 > 10x virtualStx | +| u221 | ERR_GRADUATION_TOO_HIGH — graduationThreshold > 10M STX | +| u224 | ERR_DEGENERATE_CURVE — less than 50% of supply would be released at graduation | + +## Worked examples (both verified end-to-end, real broadcasts) + +**Testnet (2026-07-03):** Deployed `launkr-test-token` (LTT), bonding mode, +500 virtual STX / 2000 STX graduation threshold, 1B supply. Created pool. +Quoted 1 STX → 1,976,087.347052 LTT via `quote-buy`. Executed +`swap-exact-stx-for-tokens` for 1 STX — received exactly the quoted amount, +fees split 0.9%/0.1% as documented. (This particular swap was broadcast in +`Allow` mode, before the `Deny`-mode post-condition gotcha above was found +— see the mainnet example below for a `Deny`-mode-verified swap.) + +**Mainnet (2026-07-16), against the redeployed contracts above:** Deployed +`SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.lft` (LFT), bonding mode, 100M +supply (min floor), 500 virtual STX / 2000 STX graduation threshold, `uri` +omitted (using the `Some("")` workaround that was in place at the time — +see the "Resolved" note above; a bare `none` has since been confirmed +correct and is what `launkr.ts` sends today). Both the deploy and +`create-pool-bonding` confirmed successfully on the first attempt — +`(ok 'SP1YNEJ....lft)`. This confirms the redeployed mainnet contracts are +correct. + +**Mainnet (2026-08-05), `Deny`-mode swap verification:** Against the same +LFT pool, ran `swap-exact-stx-for-tokens` for 0.3 STX with the full +three-post-condition set from the gotcha above — `(ok u59364737346)`, +matching the `quote-buy` result exactly. Then ran +`swap-exact-tokens-for-stx` selling 10,000 LFT with the two-post-condition +sell set — `(ok u49554)`, matching `quote-sell` exactly. Both confirmed on +the first attempt with the corrected post-conditions; the original +(caller-only) post-condition set was also tested first and reliably +produced `abort_by_post_condition`, confirming the gotcha is real and the +fix resolves it. + +See `AGENT.md` in this folder for operating rules when using this skill +autonomously, and `launkr.ts` for a reference CLI implementation with all +fixes applied (correct `uri`/`none` handling, full swap post-condition +coverage, pool-creation arg cross-check). diff --git a/launkr/launkr.test.ts b/launkr/launkr.test.ts new file mode 100644 index 00000000..1b5e7cc5 --- /dev/null +++ b/launkr/launkr.test.ts @@ -0,0 +1,262 @@ +import { describe, test, expect } from "bun:test"; +import { cvToString, serializeCV, uintCV, boolCV, tupleCV, someCV, noneCV, principalCV } from "@stacks/transactions"; +import { parseLaunkrArg, decodeCV, unwrapCV, validatePoolStepMatchesRequest } from "./launkr.js"; + +describe("parseLaunkrArg", () => { + test("principal — standard address", () => { + const cv = parseLaunkrArg({ type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW" }); + expect(cvToString(cv)).toBe("SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW"); + }); + + test("principal — contract address", () => { + const cv = parseLaunkrArg({ type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.lft" }); + expect(cvToString(cv)).toBe("SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.lft"); + }); + + test("uint", () => { + const cv = parseLaunkrArg({ type: "uint", value: "1000000000000000" }); + expect(cvToString(cv)).toBe("u1000000000000000"); + }); + + test("string-ascii", () => { + const cv = parseLaunkrArg({ type: "string-ascii", value: "MAT" }); + expect(cvToString(cv)).toBe('"MAT"'); + }); + + test("string-utf8", () => { + const cv = parseLaunkrArg({ type: "string-utf8", value: "hello" }); + expect(cvToString(cv)).toBe('u"hello"'); + }); + + // RESOLVED (2026-08-05, biwasxyz review question #3): this used to send + // someCV(stringUtf8CV("")) for a null value — see SKILL.md for why that + // workaround existed and why it was reverted. This test locks in the + // reverted (correct) behavior so it can't silently regress. + test("optional-utf8 — null value produces none, not Some(\"\")", () => { + const cv = parseLaunkrArg({ type: "optional-utf8", value: null }); + expect(cvToString(cv)).toBe("none"); + }); + + test("optional-utf8 — real value produces Some", () => { + const cv = parseLaunkrArg({ type: "optional-utf8", value: "https://example.com" }); + expect(cvToString(cv)).toBe('(some u"https://example.com")'); + }); + + test("optional-ascii — null value produces none", () => { + const cv = parseLaunkrArg({ type: "optional-ascii", value: null }); + expect(cvToString(cv)).toBe("none"); + }); + + test("unsupported type throws", () => { + expect(() => parseLaunkrArg({ type: "buffer", value: "00" })).toThrow(/Unsupported Launkr arg type/); + }); +}); + +describe("decodeCV", () => { + test("decodes a uint result", () => { + // (ok u123) as returned by Hiro's call-read endpoint + expect(decodeCV("0x0701000000000000000000000000000007b")).not.toBeUndefined(); + }); + + test("falls back to the raw hex on malformed input", () => { + expect(decodeCV("not-valid-hex")).toBe("not-valid-hex"); + }); +}); + +// FIX (biwasxyz review round 2, PR #414, blocker A): get-pool's decode bug +// (a single `.value` unwrap wasn't enough for a tuple response — every +// field inside stayed wrapped as {type, value}) shipped with zero test +// coverage of the actual decode path against a real response shape. These +// tests build a real ClarityValue, serialize it exactly like the chain +// would, and run it through decodeCV -> unwrapCV — the same round-trip +// get-pool does — rather than asserting against hand-written mock objects, +// per biwasxyz's suggestion that this is exactly what would have caught it. +describe("unwrapCV", () => { + // serializeCV returns a hex string directly on this repo's pinned + // @stacks/transactions — decodeCV strips a leading "0x" if present, so + // either form works, but normalize so this test doesn't silently start + // testing something else if that return type ever changes upstream. + function decodeHex(cv: Parameters[0]): unknown { + const serialized = serializeCV(cv); + const hex = typeof serialized === "string" ? serialized : Buffer.from(serialized).toString("hex"); + return unwrapCV(decodeCV(hex)); + } + + test("unwraps a bare uint", () => { + expect(decodeHex(uintCV(123))).toBe("123"); + }); + + test("unwraps some(uint) to the plain value", () => { + expect(decodeHex(someCV(uintCV(1976087347052n)))).toBe("1976087347052"); + }); + + test("unwraps none to null", () => { + expect(decodeHex(noneCV())).toBeNull(); + }); + + // This is the exact shape get-pool decodes: (optional (tuple ...)) with + // a bool and a uint field — the case the old one-level unwrap got wrong. + test("unwraps a get-pool-shaped tuple — every field is a plain value, not an object", () => { + const pool = decodeHex( + someCV( + tupleCV({ + active: boolCV(true), + mode: uintCV(1), + "fee-receiver": principalCV("SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW"), + }) + ) + ) as Record; + + // The bug this guards against: the old code's single-level unwrap left + // these as {type:"bool",value:true} / {type:"uint",value:"1"}, so + // String(p["mode"]) produced the literal text "[object Object]" and + // p["active"] printed as an object instead of the boolean itself. + expect(pool["active"]).toBe(true); + expect(pool["mode"]).toBe("1"); + expect(pool["fee-receiver"]).toBe("SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW"); + expect(typeof pool["active"]).not.toBe("object"); + expect(String(pool["mode"])).not.toBe("[object Object]"); + }); +}); + +describe("validatePoolStepMatchesRequest", () => { + const bondingArgs = [ + { type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.mat" }, + { type: "string-ascii", value: "My Agent Token" }, + { type: "string-ascii", value: "MAT" }, + { type: "uint", value: "6" }, + { type: "uint", value: "1000000000000000" }, + { type: "optional-utf8", value: null }, + { type: "uint", value: "500000000" }, + { type: "uint", value: "2000000000" }, + { type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW" }, + ]; + + const baseRequest = { + mode: "bonding" as const, + tokenPrincipal: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.mat", + name: "My Agent Token", + symbol: "MAT", + supply: "1000000000000000", + feeReceiver: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW", + virtualStx: "500000000", + graduationThreshold: "2000000000", + }; + + test("passes when everything matches", () => { + expect(() => + validatePoolStepMatchesRequest({ functionArgs: bondingArgs }, baseRequest) + ).not.toThrow(); + }); + + test("throws on fee-receiver mismatch", () => { + expect(() => + validatePoolStepMatchesRequest( + { functionArgs: bondingArgs }, + { ...baseRequest, feeReceiver: "SP000000000000000000002Q6VF78" } + ) + ).toThrow(/fee-receiver/); + }); + + test("throws on supply mismatch", () => { + expect(() => + validatePoolStepMatchesRequest({ functionArgs: bondingArgs }, { ...baseRequest, supply: "999" }) + ).toThrow(/supply/); + }); + + // EXTENDED (biwasxyz review, PR #414, worth-addressing #5): the curve + // parameters weren't cross-checked at all before this fix. + test("throws on virtual-stx mismatch (bonding)", () => { + expect(() => + validatePoolStepMatchesRequest( + { functionArgs: bondingArgs }, + { ...baseRequest, virtualStx: "999999999" } + ) + ).toThrow(/virtual-stx/); + }); + + test("throws on graduation-threshold mismatch (bonding)", () => { + expect(() => + validatePoolStepMatchesRequest( + { functionArgs: bondingArgs }, + { ...baseRequest, graduationThreshold: "999999999" } + ) + ).toThrow(/graduation-threshold/); + }); + + test("throws on stx-seed mismatch (direct)", () => { + const directArgs = [ + { type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.mat" }, + { type: "string-ascii", value: "My Agent Token" }, + { type: "string-ascii", value: "MAT" }, + { type: "uint", value: "6" }, + { type: "uint", value: "1000000000000000" }, + { type: "optional-utf8", value: null }, + { type: "uint", value: "100000000" }, + { type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW" }, + ]; + expect(() => + validatePoolStepMatchesRequest( + { functionArgs: directArgs }, + { + mode: "direct", + tokenPrincipal: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.mat", + name: "My Agent Token", + symbol: "MAT", + supply: "1000000000000000", + feeReceiver: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW", + stxSeed: "999999999", + } + ) + ).toThrow(/stx-seed/); + }); + + // FIX (biwasxyz review round 2, PR #414, "also worth fixing"): args[0] + // (which token the pool is even for) was never checked before. + test("throws on token-principal mismatch", () => { + expect(() => + validatePoolStepMatchesRequest( + { functionArgs: bondingArgs }, + { ...baseRequest, tokenPrincipal: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.different-token" } + ) + ).toThrow(/token:/); + }); + + // FIX (biwasxyz review round 2): the old check was `args.length < 8`, + // which accepted 8 args under bonding mode (needs 9) and read + // graduation-threshold and fee-receiver from the same slot. + test("throws on wrong arg count for bonding mode (8 args, needs 9)", () => { + const eightArgBondingArgs = bondingArgs.slice(0, 8); // drop fee-receiver + expect(() => + validatePoolStepMatchesRequest({ functionArgs: eightArgBondingArgs }, baseRequest) + ).toThrow(/expected exactly 9/); + }); + + test("throws on wrong arg count for direct mode (9 args, needs 8)", () => { + const nineArgDirectArgs = [ + ...bondingArgs.slice(0, 7), + { type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW" }, + { type: "principal", value: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW" }, + ]; + expect(() => + validatePoolStepMatchesRequest( + { functionArgs: nineArgDirectArgs }, + { + mode: "direct", + tokenPrincipal: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW.mat", + name: "My Agent Token", + symbol: "MAT", + supply: "1000000000000000", + feeReceiver: "SP1YNEJRV1AJHGVSF2EMDWP58NF2XBNPYG0R94ZWW", + stxSeed: "100000000", + } + ) + ).toThrow(/expected exactly 8/); + }); + + test("throws on too-short functionArgs", () => { + expect(() => + validatePoolStepMatchesRequest({ functionArgs: [{ type: "uint", value: "1" }] }, baseRequest) + ).toThrow(/expected exactly 9/); + }); +}); diff --git a/launkr/launkr.ts b/launkr/launkr.ts new file mode 100644 index 00000000..646b7740 --- /dev/null +++ b/launkr/launkr.ts @@ -0,0 +1,1314 @@ +#!/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, + deserializeCV, + cvToValue, + PostConditionMode, + type ClarityValue, +} from "@stacks/transactions"; +import { NETWORK, 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 { getHiroApi } from "../src/lib/services/hiro-api.js"; +import { pollTransactionConfirmation } from "../src/lib/utils/x402-recovery.js"; +import { + createStxPostCondition, + createContractStxPostCondition, + createFungiblePostCondition, + createContractFungiblePostCondition, +} 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 token deployed from Launkr's byte-frozen template defines the exact +// same fungible-token asset name internally — only the contract address +// varies. Verified against the deployed template source (mainnet + testnet): +// `(define-fungible-token strategy-token)`. Do not confuse this with the +// token's display name/symbol, which is unrelated and set at initialize(). +const LAUNKR_FT_ASSET_NAME = "strategy-token"; + +const NET_CONFIG = { + mainnet: { + singleton: "SP2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9Z367PM.lp-singleton-v6", + template: "SP2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9Z367PM.restricted-token-template-v6", + chainParam: "mainnet", + }, + testnet: { + singleton: "ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE.lp-singleton-v6", + template: "ST2ABWV7JE5SFV1A1BDS8HARP2QY7QRPGC9KJJYWE.restricted-token-template-v6", + chainParam: "testnet", + }, +} as const; + +type LaunkrNetwork = keyof typeof NET_CONFIG; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve the Launkr network from CLI option or AIBTC NETWORK config. */ +function resolveNetwork(opt?: string): LaunkrNetwork { + const n = (opt ?? NETWORK ?? "mainnet").toLowerCase(); + if (n === "mainnet" || n === "testnet") return n as LaunkrNetwork; + throw new Error(`Unknown network "${n}" — use "mainnet" or "testnet"`); +} + +/** + * FIX (biwasxyz review, PR #414, worth-addressing #8): AGENT.md tells an + * agent to "fetch GET /api/protocol fresh, every session... never hardcode + * an address from memory or from an old run" — but nothing in this file + * ever called it; every command read the addresses baked into NET_CONFIG + * at the time this script was written. That's exactly the failure mode the + * doc warns about: this contract already redeployed once (2026-07-16), and + * a second redeploy would silently point every write at a retired + * singleton and make every read report `found: false`, with nothing in + * the code to catch it. NET_CONFIG is now only the fallback for when the + * live endpoint is unreachable, not the primary source. + */ +async function fetchProtocolConfig( + network: LaunkrNetwork +): Promise<{ singleton: string; template: string }> { + const fallback = NET_CONFIG[network]; + try { + const resp = await fetch(`${LAUNKR_API}/protocol?network=${network}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = (await resp.json()) as { + contracts?: { singleton?: string; template?: string }; + }; + const { singleton, template } = data.contracts ?? {}; + if (!singleton || !template) { + throw new Error("response missing contracts.singleton/template"); + } + return { singleton, template }; + } catch (err) { + process.stderr.write( + `Warning: could not fetch live config from ${LAUNKR_API}/protocol?network=${network} ` + + `(${err instanceof Error ? err.message : String(err)}) — falling back to the address ` + + `baked into this script (${fallback.singleton}). This may be stale if Launkr has ` + + `redeployed since this version of the skill was published.\n` + ); + return { singleton: fallback.singleton, template: fallback.template }; + } +} + +/** Parse a Stacks principal string ("SP..." or "SP....contract") into a ClarityValue. */ +export 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. + * + * RESOLVED (2026-08-05, biwasxyz review question #3): an earlier version of + * this function substituted `someCV(stringUtf8CV(""))` for a null optional + * value, working around a `BadFunctionArgument` broadcast rejection seen + * against a *different* environment (the published `@aibtc/mcp-server` npm + * package's own dependency resolution). + * + * CORRECTION (2026-08-14): this comment previously cited two testnet txids + * as verification against this repo's pinned `@stacks/transactions@7.3.1`. + * Those txids were written before the verification was actually run and do + * not exist on-chain — that was a mistake, not a stale reference to a real + * result. The underlying claim has now actually been verified: a bare + * `noneCV()` for this same optional-uri argument, signed with this exact + * pinned dependency version and broadcast for real, confirms successfully — + * mainnet txid + * `29b7e58d636d2be118ca658707220e3f5ff19100fbb264f5aeb00c765202e390`, + * `(ok true)`, calling `set-token-uri` with `noneCV()` on a live Launkr + * token. (Testnet was used for the original, unverified claim but wasn't + * available for re-verification — its API is returning nonce 0 / balance 0 + * for addresses with known prior history, consistent with a testnet reset; + * mainnet was used instead. The mechanism being verified — optional-argument + * encoding — is identical on both networks.) The bug behind the original + * workaround was real but environment-specific, not a Stacks or Clarity + * issue — reverted to sending a proper `none` rather than a permanent + * empty-string placeholder. + */ +export 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}"`); + } +} + +/** + * FIX (arc0btc review, PR #414): the Launkr API builds the pool-creation + * functionArgs server-side from our request, but we never cross-checked + * that what comes back actually matches what we asked for. A buggy or + * compromised API response could silently swap `fee-receiver` to a + * different address, or change `supply`, and we'd deploy + create the pool + * without ever noticing — routing future swap fees to an address we don't + * control. Fail loudly, before spending any gas, if these don't match. + * + * EXTENDED (biwasxyz review round 1, PR #414, worth-addressing #5): the + * original version only checked name/symbol/supply/fee-receiver — not the + * curve parameters (virtual-stx/graduation-threshold for bonding, stx-seed + * for direct), even though those define the entire price curve. + * + * EXTENDED AGAIN (biwasxyz review round 2): three more gaps. + * - The curve-parameter checks above only ran when the caller happened to + * pass the corresponding flag (`!= null`) — but `--virtual-stx`/ + * `--graduation-threshold`/`--stx-seed` were optional CLI flags, so the + * *default*, most common invocation validated zero curve parameters. + * Resolved structurally rather than by widening this function: `launch` + * and `create-pool` now require these flags per mode (mirroring + * blocker B's fix for `--stx-seed`), so `requested.virtualStx` etc. are + * always defined by the time this runs — nothing here needed to change + * for that part, but it's why the `!= null` guards below are no longer + * reachable as "not provided." + * - The arity check (`args.length < 8`) accepted 8 args for bonding (which + * needs 9) and 9 for direct (needs 8) — in the 8-arg bonding case + * `args[7]` was read as both graduation-threshold and fee-receiver. Now + * checked as an exact length per mode. + * - `args[0]`, the token principal — which pool the args are even for — + * was never checked. A response could pass `verifyDeploySourceMatchesTemplate` + * on step 1 and still point step 2's pool creation at a different token. + * Now compared against the token principal derived locally from the + * deployer address + contract name (or passed in directly by `create-pool`, + * which already knows the target token from `--token`). + * + * Positional args differ by mode: + * bonding: token, name, symbol, decimals, supply, uri, virtual-stx, graduation-threshold, fee-receiver (9 args) + * direct: token, name, symbol, decimals, supply, uri, stx-seed, fee-receiver (8 args) + */ +export function validatePoolStepMatchesRequest( + poolStep: { functionArgs?: Array<{ type: string; value: unknown }> }, + requested: { + mode: "bonding" | "direct"; + tokenPrincipal: string; + supply: string; + feeReceiver: string; + name: string; + symbol: string; + virtualStx?: string; + graduationThreshold?: string; + stxSeed?: string; + } +): void { + const args = poolStep.functionArgs; + const expectedLength = requested.mode === "bonding" ? 9 : 8; + if (!args || args.length !== expectedLength) { + throw new Error( + `Launkr API returned ${args?.length ?? 0} pool-creation args for mode ` + + `"${requested.mode}", expected exactly ${expectedLength}` + ); + } + + const tokenArg = String(args[0]?.value); + const nameArg = String(args[1]?.value); + const symbolArg = String(args[2]?.value); + const supplyArg = String(args[4]?.value); + const feeReceiverArg = String(args[args.length - 1]?.value); + + const mismatches: string[] = []; + if (tokenArg !== requested.tokenPrincipal) { + mismatches.push( + `token: expected "${requested.tokenPrincipal}", API returned "${tokenArg}"` + ); + } + if (nameArg !== requested.name) { + mismatches.push(`name: requested "${requested.name}", API returned "${nameArg}"`); + } + if (symbolArg !== requested.symbol) { + mismatches.push(`symbol: requested "${requested.symbol}", API returned "${symbolArg}"`); + } + if (supplyArg !== requested.supply) { + mismatches.push(`supply: requested ${requested.supply}, API returned ${supplyArg}`); + } + if (feeReceiverArg !== requested.feeReceiver) { + mismatches.push(`fee-receiver: requested ${requested.feeReceiver}, API returned ${feeReceiverArg}`); + } + + if (requested.mode === "bonding") { + const virtualStxArg = String(args[6]?.value); + const graduationThresholdArg = String(args[7]?.value); + if (requested.virtualStx != null && virtualStxArg !== requested.virtualStx) { + mismatches.push( + `virtual-stx: requested ${requested.virtualStx}, API returned ${virtualStxArg}` + ); + } + if ( + requested.graduationThreshold != null && + graduationThresholdArg !== requested.graduationThreshold + ) { + mismatches.push( + `graduation-threshold: requested ${requested.graduationThreshold}, API returned ${graduationThresholdArg}` + ); + } + } else { + const stxSeedArg = String(args[6]?.value); + if (requested.stxSeed != null && stxSeedArg !== requested.stxSeed) { + mismatches.push(`stx-seed: requested ${requested.stxSeed}, API returned ${stxSeedArg}`); + } + } + + if (mismatches.length > 0) { + throw new Error( + `Refusing to proceed — Launkr API's pool-creation args don't match what was requested:\n` + + mismatches.map((m) => ` - ${m}`).join("\n") + ); + } +} + +/** + * FIX (biwasxyz review, PR #414, worth-addressing #4): the token's Clarity + * source came straight from the API and was deployed under the user's own + * key with no local check — the much larger trust surface compared to the + * pool-creation args above, since it's arbitrary contract code. The + * singleton already gates on a hash of the byte-frozen template, so + * fetching that template on-chain and comparing before deploying catches a + * bad/compromised API response *before* spending gas rather than after + * (the singleton would reject a mismatched deploy anyway via + * `ERR_TOKEN_NOT_OURS`, but only after the deploy fee is already spent). + */ +async function verifyDeploySourceMatchesTemplate( + codeBody: string, + network: LaunkrNetwork, + templateContractId: string +): Promise { + const { source: templateSource } = await getHiroApi(network).getContractSource( + templateContractId + ); + if (codeBody !== templateSource) { + throw new Error( + "Refusing to deploy — the API's clarityCode does not byte-match the " + + `on-chain template (${templateContractId}). This would be rejected ` + + "by the singleton anyway (ERR_TOKEN_NOT_OURS), but checking first " + + "avoids spending the deploy fee on a token that can never get a pool." + ); + } +} + +/** + * Decode a hex-encoded Clarity value returned by Hiro's call-read endpoint. + * Returns a `cvToValue`-shaped tree (nodes are `{type, value}`, all the way + * down) or the raw hex on failure. Pass the result through `unwrapCV` to + * get a plain JS value/object — `decodeCV` alone is not usable directly for + * anything beyond a single scalar. + */ +export 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; + } +} + +/** + * FIX (biwasxyz review, PR #414, blocker A): `cvToValue` doesn't flatten to + * plain JS — every node, at every depth, stays wrapped as `{type, value}`. + * `get-pool`'s old code unwrapped exactly one level (assuming that was the + * "ok" or "some" wrapper) and then read tuple fields directly off the + * result — but a tuple's *fields* are each still `{type, value}` nodes one + * level further down, so every field came back as an object + * (`String(...)` → `"[object Object]"`) or `undefined`. Verified directly: + * a real `get-pool` response run through the old code printed + * `mode: "[object Object]"` and `active: {type:"bool",value:true}` instead + * of `mode: "bonding"` / `active: true`. + * + * This recurses through the whole tree instead of assuming a fixed depth, + * so it's correct for any Clarity value shape — a bare value, a `some`, + * a tuple, a list, or nested combinations — not just the ones this file + * happens to call today. + */ +export function unwrapCV(node: unknown): unknown { + if (node === null || typeof node !== "object") return node; + const { value } = node as { type?: unknown; value?: unknown }; + if (!("type" in (node as object)) || !("value" in (node as object))) return node; + + if (Array.isArray(value)) return value.map(unwrapCV); + + if (value !== null && typeof value === "object") { + // A nested single Clarity value (e.g. the payload of a `some` or `ok`) + // looks the same shape as the node we're already unwrapping — recurse. + if ("type" in value && "value" in value) return unwrapCV(value); + // Otherwise this is a tuple's field map: { fieldName: {type, value}, ... }. + const result: Record = {}; + for (const [key, fieldValue] of Object.entries(value as Record)) { + result[key] = unwrapCV(fieldValue); + } + return result; + } + + // Already a plain scalar (string/number/boolean/null) — nothing to unwrap. + return value; +} + +// FIX (biwasxyz review, PR #414, worth-addressing #7): terminal statuses a +// Stacks tx can land in without succeeding. Used by `waitForConfirmation` +// below, which wraps the shared `pollTransactionConfirmation` (from +// src/lib/utils/x402-recovery.js — reused instead of a hand-rolled poller +// so this also picks up the Hiro API key header that helper attaches). +const ABORT_STATUSES = [ + "abort_by_response", + "abort_by_post_condition", + "dropped_replace_by_fee", + "dropped_too_expensive", + "dropped_stale_garbage_collect", + "dropped_replace_across_fork", + "dropped_problematic", +]; + +/** + * Wait for a transaction to reach a terminal status, using the shared + * poller. Throws if it aborts/drops or if the timeout is exceeded. + */ +async function waitForConfirmation( + txid: string, + network: LaunkrNetwork, + timeoutMs = 300_000 +): Promise { + process.stderr.write(`Waiting for tx ${txid} to confirm...\n`); + const result = await pollTransactionConfirmation(txid, network, timeoutMs, 6_000); + + if (result.status === "success") { + process.stderr.write(`Confirmed: ${txid}\n`); + return; + } + if (ABORT_STATUSES.includes(result.status)) { + throw new Error(`Transaction failed with status: ${result.status}`); + } + throw new Error(`Timed out waiting for tx ${txid} after ${timeoutMs / 1000}s (last status: ${result.status})`); +} + +// --------------------------------------------------------------------------- +// 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 ", + "Required if --mode bonding. Virtual STX reserve in uSTX (min 500000000 = 500 STX)" + ) + .option( + "--graduation-threshold ", + "Required if --mode bonding. Real STX to collect before graduating (min 2000000000 = 2000 STX, max 10x virtual-stx)" + ) + .option( + "--stx-seed ", + "Required if --mode 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 { + // FIX (biwasxyz review, PR #414, worth-addressing #9): validate --mode + // locally rather than letting a typo or case mismatch reach the API — + // `opts.mode === "direct"` further down (the post-condition guard for + // the STX seed) is case-sensitive, so e.g. "Direct" would silently + // skip that guard instead of erroring. + if (opts.mode !== "bonding" && opts.mode !== "direct") { + throw new Error(`--mode must be exactly "bonding" or "direct", got "${opts.mode}"`); + } + const mode = opts.mode as "bonding" | "direct"; + + // FIX (biwasxyz review round 2, PR #414, blocker B): `--stx-seed` (and + // the bonding equivalents) were plain `.option()`s, so a `--mode + // direct` run with no `--stx-seed` proceeded all the way through the + // deploy — spending that fee — before failing at pool creation with + // `abort_by_post_condition` (the post-condition guarding the seed + // can't be built from `undefined`). Fail before deploying, not after. + if (mode === "bonding" && (!opts.virtualStx || !opts.graduationThreshold)) { + throw new Error( + "--virtual-stx and --graduation-threshold are required when --mode is bonding" + ); + } + if (mode === "direct" && !opts.stxSeed) { + throw new Error("--stx-seed is required when --mode is direct"); + } + + // FIX (biwasxyz review, PR #414, blocker #2): the network that + // actually gets signed/broadcast to is account.network (set by which + // wallet is loaded, via the NETWORK env var at wallet-creation time) + // — a `--network` flag here can never change that, since + // callContract/deployContract derive their network from the account, + // not from a parameter we control. Rather than have a flag that looks + // like it selects the network but silently doesn't, derive everything + // from the account so there's only one source of truth. + const account = await getAccount(); + const network = account.network; + const { chainParam } = NET_CONFIG[network]; + const { singleton, template } = await fetchProtocolConfig(network); + + // ----------------------------------------------------------------------- + // 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, + 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; + 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)"); + } + + // FIX (biwasxyz review round 2, PR #414, "also worth fixing"): the + // function actually called comes from the API, but nothing checked it + // agreed with the locally-validated --mode. A response could pass + // every arg check above while pointing at the *other* mode's + // function — `--mode bonding` + an API response of + // `create-pool-direct` would pass every other check here and then + // broadcast a call that pulls real STX with no post-condition, since + // the post-condition array below is built from the local `mode`. + const expectedFunctionName = mode === "bonding" ? "create-pool-bonding" : "create-pool-direct"; + if (poolStep.functionName !== expectedFunctionName) { + throw new Error( + `Refusing to proceed — requested mode "${mode}" but the API's pool-creation ` + + `step calls "${poolStep.functionName}", not "${expectedFunctionName}"` + ); + } + + // FIX (arc0btc review, PR #414; extended twice by biwasxyz — round 1 + // worth-addressing #5 added the curve parameters, round 2 added the + // exact-arity check and the token-principal check): verify the API's + // pool-creation args actually match what we asked for, before + // spending any gas at all. + validatePoolStepMatchesRequest(poolStep, { + mode, + tokenPrincipal: `${account.address}.${deployStep.contractName}`, + name: opts.name, + symbol: opts.symbol, + supply: opts.supply, + feeReceiver: opts.feeReceiver, + virtualStx: opts.virtualStx, + graduationThreshold: opts.graduationThreshold, + stxSeed: opts.stxSeed, + }); + + // FIX (biwasxyz review, PR #414, worth-addressing #4): confirm the + // deploy source is really the approved template before spending gas + // on it — see verifyDeploySourceMatchesTemplate for why. + await verifyDeploySourceMatchesTemplate(deployStep.clarityCode, network, template); + + // ----------------------------------------------------------------------- + // 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 }), + }); + + process.stderr.write(`Deploy tx broadcast: ${deployResult.txid}\n`); + process.stderr.write( + `If step 2 below fails or this process is interrupted, the token is ` + + `already deployed at ${account.address}.${deployStep.contractName} — ` + + `re-run with the \`create-pool\` subcommand instead of \`launch\` to ` + + `resume without deploying a second token.\n` + ); + + // ----------------------------------------------------------------------- + // Step 3 — Wait for deploy to confirm + // ----------------------------------------------------------------------- + await waitForConfirmation(deployResult.txid, network); + + // ----------------------------------------------------------------------- + // 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 = + mode === "direct" && opts.stxSeed + ? [createStxPostCondition(account.address, "eq", BigInt(opts.stxSeed))] + : []; + + process.stderr.write(`Creating ${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 }), + }); + + // FIX (biwasxyz review round 2, PR #414, "also worth fixing"): this + // used to print `success: true` right after *broadcasting* the pool + // tx — the deploy is awaited via waitForConfirmation above, but the + // pool creation wasn't, so a caller had no way to tell "pool created" + // from "pool creation is still pending" from "pool creation aborted + // on-chain" from this JSON alone. Wait for it the same way. + process.stderr.write(`Pool tx broadcast: ${poolResult.txid}\n`); + await waitForConfirmation(poolResult.txid, network); + + 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}`, + chainExplorerUrl: `https://explorer.hiro.so/txid/${poolResult.txid}?chain=${chainParam}`, + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// create-pool +// --------------------------------------------------------------------------- +// +// FIX (biwasxyz review, PR #414, worth-addressing #6): `launch` is two +// transactions with no recovery path — if step 2 (pool creation) fails, or +// the process is killed during the 5-minute confirmation wait, the token +// sits deployed with no pool, and re-running `launch` deploys a *second* +// token rather than resuming. This subcommand takes an already-deployed +// token and runs only the pool-creation step, so `launch` failing partway +// through has a documented way out (see the message `launch` itself prints +// after a successful deploy). +// +// Builds the create-pool-* call directly from the same args a `launch` +// invocation would have used, rather than round-tripping through +// /api/launch again — the function signature is fully documented (see +// SKILL.md) and entirely derivable from user-supplied input, so there's +// nothing the API would add here except another chance to disagree with +// what was actually deployed. + +program + .command("create-pool") + .description( + "Create a pool for a token that's already deployed but has no pool yet " + + "— the recovery path when `launch` deployed the token but failed (or " + + "was interrupted) before/during pool creation. Requires an unlocked wallet." + ) + .requiredOption( + "--token ", + "Full principal of the already-deployed token (ADDRESS.contract-name)" + ) + .requiredOption("--name ", "Token display name — must match what was deployed") + .requiredOption("--symbol ", "Token symbol — must match what was deployed") + .requiredOption( + "--supply ", + "Total supply in atomic units — must match what was deployed" + ) + .requiredOption("--mode ", "Pool mode: 'bonding' or 'direct'") + .requiredOption("--fee-receiver
", "STX address that receives 90% of swap fees") + .option("--virtual-stx ", "Required if --mode bonding. Virtual STX reserve in uSTX") + .option("--graduation-threshold ", "Required if --mode bonding. Real STX to collect before graduating") + .option("--stx-seed ", "Required if --mode direct. Real STX to seed the pool in uSTX") + .option("--uri ", "Optional token metadata URI") + .option("--fee ", "Fee preset (low|medium|high) or micro-STX amount") + .action(async (opts) => { + try { + if (opts.mode !== "bonding" && opts.mode !== "direct") { + throw new Error(`--mode must be exactly "bonding" or "direct", got "${opts.mode}"`); + } + const mode = opts.mode as "bonding" | "direct"; + + // FIX (biwasxyz review round 2, PR #414, "also worth fixing"): these + // were plain `.option()`s, so e.g. `create-pool --mode bonding` with + // no `--virtual-stx` reached `BigInt(undefined)` and crashed with + // `Cannot convert undefined to a BigInt` — an unhelpful error on the + // one command someone reaches only after `launch` already stranded a + // token. Same fix as `launch`: fail with a clear message first. + if (mode === "bonding" && (!opts.virtualStx || !opts.graduationThreshold)) { + throw new Error( + "--virtual-stx and --graduation-threshold are required when --mode is bonding" + ); + } + if (mode === "direct" && !opts.stxSeed) { + throw new Error("--stx-seed is required when --mode is direct"); + } + + const account = await getAccount(); + const network = account.network; + const { singleton } = await fetchProtocolConfig(network); + const [singletonAddr, singletonName] = singleton.split("."); + + // FIX (biwasxyz review round 2, PR #414, "also worth fixing"): this + // hardcoded `uintCV(6)` while `launch` takes decimals from whatever + // the API's deploy step actually used. If those ever disagreed, this + // recovery path would silently create a differently-configured pool + // than `launch` would have. Reading it back from the already-deployed + // token is the only source that can't drift from what's actually on + // chain — it's not a parameter to get right, it's a fact to look up. + const decimalsResult = await getHiroApi(network).callReadOnlyFunction( + opts.token, + "get-decimals", + [], + account.address + ); + if (!decimalsResult.okay) { + throw new Error(`Could not read decimals from ${opts.token}: ${decimalsResult.cause}`); + } + const decimals = Number(unwrapCV(decodeCV(decimalsResult.result ?? ""))); + if (!Number.isInteger(decimals)) { + throw new Error(`Unexpected get-decimals result from ${opts.token}: ${decimalsResult.result}`); + } + + const uriArg = opts.uri ? someCV(stringUtf8CV(opts.uri)) : noneCV(); + + const functionArgs = + mode === "bonding" + ? [ + parsePrincipalCV(opts.token), + stringAsciiCV(opts.name), + stringAsciiCV(opts.symbol), + uintCV(decimals), + uintCV(BigInt(opts.supply)), + uriArg, + uintCV(BigInt(opts.virtualStx)), + uintCV(BigInt(opts.graduationThreshold)), + parsePrincipalCV(opts.feeReceiver), + ] + : [ + parsePrincipalCV(opts.token), + stringAsciiCV(opts.name), + stringAsciiCV(opts.symbol), + uintCV(decimals), + uintCV(BigInt(opts.supply)), + uriArg, + uintCV(BigInt(opts.stxSeed)), + parsePrincipalCV(opts.feeReceiver), + ]; + + const postConditions = + mode === "direct" + ? [createStxPostCondition(account.address, "eq", BigInt(opts.stxSeed))] + : []; + + const fee = await resolveFee(opts.fee, network, "contract_call"); + + const result = await callContract(account, { + contractAddress: singletonAddr, + contractName: singletonName, + functionName: mode === "bonding" ? "create-pool-bonding" : "create-pool-direct", + functionArgs, + postConditionMode: PostConditionMode.Deny, + ...(postConditions.length > 0 && { postConditions }), + ...(fee !== undefined && { fee }), + }); + + process.stderr.write(`Pool tx broadcast: ${result.txid}\n`); + await waitForConfirmation(result.txid, network); + + printJson({ + success: true, + token: opts.token, + poolTxid: result.txid, + network, + explorerUrl: getExplorerTxUrl(result.txid, network), + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// set-fee-receiver / accept-fee-receiver +// --------------------------------------------------------------------------- +// +// Answers biwasxyz review question #5: the singleton's two-step +// fee-receiver transfer (`set-pending-fee-receiver` proposed by the +// current receiver, `accept-fee-receiver` confirmed by the new one) exists +// on-chain but wasn't exposed by this skill — meaning a launch with the +// wrong fee-receiver had no correction path through this CLI. Exposed here +// since the fee-receiver collects 90% of swap volume permanently; not +// having a way to fix a mistake was a real sharp edge. + +program + .command("set-fee-receiver") + .description( + "Propose a new fee-receiver for a token's pool (step 1 of 2). Must be " + + "called by the pool's *current* fee-receiver. The new address must " + + "call accept-fee-receiver to complete the transfer. Requires an " + + "unlocked wallet." + ) + .requiredOption("--token ", "Full token principal") + .requiredOption("--new-receiver
", "STX address to propose as the new fee-receiver") + .option("--fee ", "Fee preset (low|medium|high) or micro-STX amount") + .action(async (opts) => { + try { + const account = await getAccount(); + const network = account.network; + const { singleton } = await fetchProtocolConfig(network); + const [singletonAddr, singletonName] = singleton.split("."); + const fee = await resolveFee(opts.fee, network, "contract_call"); + + const result = await callContract(account, { + contractAddress: singletonAddr, + contractName: singletonName, + functionName: "set-pending-fee-receiver", + functionArgs: [parsePrincipalCV(opts.token), parsePrincipalCV(opts.newReceiver)], + postConditionMode: PostConditionMode.Deny, + ...(fee !== undefined && { fee }), + }); + + printJson({ + success: true, + txid: result.txid, + token: opts.token, + newReceiver: opts.newReceiver, + network, + explorerUrl: getExplorerTxUrl(result.txid, network), + note: "The proposed address must now call accept-fee-receiver to complete the transfer.", + }); + } catch (error) { + handleError(error); + } + }); + +program + .command("accept-fee-receiver") + .description( + "Accept a pending fee-receiver transfer for a token's pool (step 2 of " + + "2). Must be called by the address set-fee-receiver proposed. " + + "Requires an unlocked wallet." + ) + .requiredOption("--token ", "Full token principal") + .option("--fee ", "Fee preset (low|medium|high) or micro-STX amount") + .action(async (opts) => { + try { + const account = await getAccount(); + const network = account.network; + const { singleton } = await fetchProtocolConfig(network); + const [singletonAddr, singletonName] = singleton.split("."); + const fee = await resolveFee(opts.fee, network, "contract_call"); + + const result = await callContract(account, { + contractAddress: singletonAddr, + contractName: singletonName, + functionName: "accept-fee-receiver", + functionArgs: [parsePrincipalCV(opts.token)], + postConditionMode: PostConditionMode.Deny, + ...(fee !== undefined && { fee }), + }); + + printJson({ + success: true, + txid: result.txid, + token: opts.token, + network, + explorerUrl: getExplorerTxUrl(result.txid, network), + }); + } 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" + ) + .option("--network ", "mainnet or testnet") + .action(async (opts) => { + try { + const network = resolveNetwork(opts.network); + const { singleton } = await fetchProtocolConfig(network); + + let sender: string; + try { + sender = await getWalletAddress(); + } catch { + // Fallback — any valid address works for read-only calls + sender = + network === "mainnet" + ? "SP000000000000000000002Q6VF78" + : "ST000000000000000000002AMW42H"; + } + + // FIX (biwasxyz review, PR #414, worth-addressing #7): use the shared + // Hiro client (adds the API key header, avoiding rate limits) instead + // of a hand-rolled fetch. + const result = await getHiroApi(network).callReadOnlyFunction( + singleton, + "get-pool", + [parsePrincipalCV(opts.token)], + sender + ); + + if (!result.okay) { + throw new Error(`get-pool failed: ${result.cause ?? result.result}`); + } + + // FIX (biwasxyz review, PR #414, blocker A): a single `.value` unwrap + // isn't enough — `get-pool` returns `(optional (tuple ...))`, and + // every field *inside* the tuple is its own {type, value} node. + // `unwrapCV` recurses all the way down instead of assuming one level. + const pool = unwrapCV(decodeCV(result.result ?? "")); + + 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") + .option("--network ", "mainnet or testnet") + .action(async (opts) => { + try { + const network = resolveNetwork(opts.network); + const { singleton } = await fetchProtocolConfig(network); + + let sender: string; + try { + sender = await getWalletAddress(); + } catch { + sender = + network === "mainnet" + ? "SP000000000000000000002Q6VF78" + : "ST000000000000000000002AMW42H"; + } + + const result = await getHiroApi(network).callReadOnlyFunction( + singleton, + "quote-buy", + [parsePrincipalCV(opts.token), uintCV(BigInt(opts.stxIn))], + sender + ); + + if (!result.okay) { + throw new Error(`quote-buy failed: ${result.cause ?? result.result}`); + } + + // (some uN) → the uint as a string; none → null. + const unwrapped = unwrapCV(decodeCV(result.result ?? "")); + const tokensOut = unwrapped == null ? null : String(unwrapped); + + 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") + .option("--network ", "mainnet or testnet") + .action(async (opts) => { + try { + const network = resolveNetwork(opts.network); + const { singleton } = await fetchProtocolConfig(network); + + let sender: string; + try { + sender = await getWalletAddress(); + } catch { + sender = + network === "mainnet" + ? "SP000000000000000000002Q6VF78" + : "ST000000000000000000002AMW42H"; + } + + const result = await getHiroApi(network).callReadOnlyFunction( + 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 unwrapped = unwrapCV(decodeCV(result.result ?? "")); + const stxOut = unwrapped == null ? null : String(unwrapped); + + 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 { + // FIX (biwasxyz review, PR #414, blocker #2) — see the identical note + // in `launch`: the account's own network is the only thing that + // actually determines the broadcast target, so it's the only thing + // that should select which singleton/config we use. + const account = await getAccount(); + const network = account.network; + const { chainParam } = NET_CONFIG[network]; + const { singleton } = await fetchProtocolConfig(network); + 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), + ], + // FIX (biwasxyz review, PR #414, blocker #1 — verified on-chain, + // both testnet and mainnet, 2026-08-05): Deny mode requires EVERY + // principal that moves an asset in the transaction to be covered, + // not just the caller. A buy has the singleton sending back BOTH + // the FT payout and STX (the two fee legs, treasury + protocol) — + // omitting those two conditions aborts with abort_by_post_condition + // even though the underlying contract call succeeds. One + // post-condition per (principal, asset) covers the *aggregate* + // amount that principal sends of that asset across the whole tx — + // confirmed empirically, no need for one condition per fee leg. + postConditionMode: PostConditionMode.Deny, + postConditions: [ + // Caller: sends exactly stxIn uSTX, no more, no less. + createStxPostCondition(account.address, "eq", BigInt(opts.stxIn)), + // Singleton: pays out the two STX fee legs (treasury + protocol). + // Not meaningful to bound tightly here — the amount is the + // contract's own fee math, not attacker-controlled input — so + // `gte 0` just satisfies Deny mode's "every sender is covered" + // rule without asserting anything false. + createContractStxPostCondition(singleton, "gte", 0n), + // Singleton: pays out at least minTokensOut of the FT — this + // *is* the meaningful guard (the actual slippage protection). + createContractFungiblePostCondition( + singleton, + opts.token, + LAUNKR_FT_ASSET_NAME, + "gte", + BigInt(opts.minTokensOut) + ), + ], + ...(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), + chainExplorerUrl: `https://explorer.hiro.so/txid/${result.txid}?chain=${chainParam}`, + }); + } 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." + ) + .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 { + // FIX (biwasxyz review, PR #414, blocker #2) — see the identical note + // in `launch`. + const account = await getAccount(); + const network = account.network; + const { chainParam } = NET_CONFIG[network]; + const { singleton } = await fetchProtocolConfig(network); + 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), + ], + // The FT asset name is NOT per-token-variable — every Launkr token + // uses the identical internal asset name `strategy-token` (verified + // against the deployed byte-frozen template source, both mainnet + // and testnet). Only the contract address varies. + // + // FIX (biwasxyz review, PR #414, blocker #1 — verified on-chain, + // both testnet and mainnet, 2026-08-05): a sell has the singleton + // paying out STX (the swap proceeds *and* the two fee legs) — Deny + // mode requires that covered too, not just the caller's FT leg. + // One post-condition per (principal, asset) covers the aggregate + // amount sent, confirmed empirically — a single `gte minStxOut` on + // the singleton's uSTX is both correct and the meaningful guard + // here (the actual slippage protection). + postConditionMode: PostConditionMode.Deny, + postConditions: [ + createFungiblePostCondition( + account.address, + opts.token, + LAUNKR_FT_ASSET_NAME, + "eq", + BigInt(opts.tokensIn) + ), + createContractStxPostCondition(singleton, "gte", BigInt(opts.minStxOut)), + ], + ...(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), + chainExplorerUrl: `https://explorer.hiro.so/txid/${result.txid}?chain=${chainParam}`, + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// Parse +// --------------------------------------------------------------------------- + +if (import.meta.main) { + program.parse(); +} diff --git a/skills.json b/skills.json index 61c143f8..c2316c12 100644 --- a/skills.json +++ b/skills.json @@ -1,6 +1,6 @@ { - "version": "0.41.0", - "generated": "2026-05-11T17:17:00.899Z", + "version": "0.42.0", + "generated": "2026-08-05T17:10:11.807Z", "skills": [ { "name": "agent-lookup", @@ -1534,6 +1534,39 @@ "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", + "create-pool", + "get-pool", + "quote-buy", + "quote-sell", + "swap-buy", + "swap-sell", + "set-fee-receiver", + "accept-fee-receiver" + ], + "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.",