-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathlaunkr.ts
More file actions
1314 lines (1209 loc) · 53.6 KB
/
Copy pathlaunkr.ts
File metadata and controls
1314 lines (1209 loc) · 53.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/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 <subcommand> [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<void> {
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<string, unknown> = {};
for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
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<void> {
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 <name>", "Token display name (max 32 chars)")
.requiredOption("--symbol <symbol>", "Token symbol (max 32 chars)")
.requiredOption(
"--supply <atomic>",
"Total supply in atomic units (min 100000000000000 = 100M @ 6 decimals)"
)
.requiredOption(
"--mode <mode>",
"Pool mode: 'bonding' (virtual reserves, 1% fee) or 'direct' (real STX seed, 5% fee)"
)
.requiredOption(
"--fee-receiver <address>",
"STX address that receives 90% of swap fees"
)
.option(
"--virtual-stx <uSTX>",
"Required if --mode bonding. Virtual STX reserve in uSTX (min 500000000 = 500 STX)"
)
.option(
"--graduation-threshold <uSTX>",
"Required if --mode bonding. Real STX to collect before graduating (min 2000000000 = 2000 STX, max 10x virtual-stx)"
)
.option(
"--stx-seed <uSTX>",
"Required if --mode direct. Real STX to seed the pool in uSTX (min 100000000 = 100 STX)"
)
.option("--uri <uri>", "Optional token metadata URI")
.option("--fee <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<string, string | undefined> = {
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 <principal>",
"Full principal of the already-deployed token (ADDRESS.contract-name)"
)
.requiredOption("--name <name>", "Token display name — must match what was deployed")
.requiredOption("--symbol <symbol>", "Token symbol — must match what was deployed")
.requiredOption(
"--supply <atomic>",
"Total supply in atomic units — must match what was deployed"
)
.requiredOption("--mode <mode>", "Pool mode: 'bonding' or 'direct'")
.requiredOption("--fee-receiver <address>", "STX address that receives 90% of swap fees")
.option("--virtual-stx <uSTX>", "Required if --mode bonding. Virtual STX reserve in uSTX")
.option("--graduation-threshold <uSTX>", "Required if --mode bonding. Real STX to collect before graduating")
.option("--stx-seed <uSTX>", "Required if --mode direct. Real STX to seed the pool in uSTX")
.option("--uri <uri>", "Optional token metadata URI")
.option("--fee <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 <principal>", "Full token principal")
.requiredOption("--new-receiver <address>", "STX address to propose as the new fee-receiver")
.option("--fee <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 <principal>", "Full token principal")
.option("--fee <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 <principal>",
"Full token principal in ADDRESS.contract-name format"
)
.option("--network <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<string, string> = {
"0": "direct",
"1": "bonding",
"2": "graduated",
};
const p = pool as Record<string, unknown>;
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"],
});