@1sat/cli 0.0.114 → 0.0.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,14 +232,27 @@ OpNS (Ordinals Name System) binds your BAP identity to an on-chain name inscript
232
232
 
233
233
  ### Sweep
234
234
 
235
- Import assets from an external private key (e.g., a legacy P2PKH wallet) into your BRC-100 wallet.
235
+ Import assets from an external private key (legacy P2PKH / Yours backup WIF) into the BRC-100 wallet.
236
236
 
237
237
  ```bash
238
- 1sat sweep scan --wif <key> # Preview what a key holds (BSV, ordinals, tokens)
239
- 1sat sweep import --wif <key> # Import everything into your wallet
238
+ 1sat sweep scan --wif <key>
239
+ 1sat sweep import --wif <key> --dry-run
240
+ 1sat sweep import --wif <key>
241
+ 1sat sweep import --wif <key> --only opns,bsv20
242
+ 1sat sweep import --wif <key> --skip bsv21
240
243
  ```
241
244
 
242
- `scan` is non-destructive. `import` broadcasts transactions — confirm when prompted or pass `--yes`.
245
+ `scan` is the full inventory. `import --dry-run` is the same plan after `--only` / `--skip`. `import` with no class flags attempts every class that can move into BRC-100:
246
+
247
+ | Class | Destination |
248
+ |---|---|
249
+ | `bsv` | Funding |
250
+ | `ordinals` | `1sat` basket |
251
+ | `opns` | `opns` basket |
252
+ | `bsv20` | `bsv20` basket (ticker identity) |
253
+ | `bsv21` | `bsv21` basket (deploy-outpoint identity) |
254
+
255
+ Listed OrdLocks cancel into the destination in the same transaction. Time-locks and RUN outputs stay as leftover. `--only` and `--skip` take `bsv,ordinals,opns,bsv20,bsv21`. Confirm when prompted, or pass `--yes`.
243
256
 
244
257
  ---
245
258
 
package/dist/cli.js CHANGED
@@ -1328,13 +1328,41 @@ var init_help = __esm(() => {
1328
1328
  subcommands: [
1329
1329
  {
1330
1330
  name: "scan",
1331
- description: "Scan an address for sweepable UTXOs (BSV, ordinals, BSV21)",
1332
- args: [{ flag: "--wif", values: "<key>", required: true }]
1331
+ description: "Show every UTXO class at an address (BSV, ordinals, OpNS, BSV-20, BSV-21, leftover locks/RUN)",
1332
+ args: [
1333
+ { flag: "--wif", values: "<key>", required: true },
1334
+ {
1335
+ flag: "--only",
1336
+ values: "<bsv,ordinals,opns,bsv20,bsv21>",
1337
+ description: "Limit the report to these classes"
1338
+ },
1339
+ {
1340
+ flag: "--skip",
1341
+ values: "<class,...>",
1342
+ description: "Omit classes from the report"
1343
+ }
1344
+ ]
1333
1345
  },
1334
1346
  {
1335
1347
  name: "import",
1336
- description: "Sweep UTXOs from a WIF into the wallet",
1337
- args: [{ flag: "--wif", values: "<key>", required: true }]
1348
+ description: "Sweep transferable UTXOs from a WIF into the wallet. Default is all classes we can move to BRC-100 (including listed OrdLocks). Locks and RUN stay.",
1349
+ args: [
1350
+ { flag: "--wif", values: "<key>", required: true },
1351
+ {
1352
+ flag: "--only",
1353
+ values: "<bsv,ordinals,opns,bsv20,bsv21>",
1354
+ description: "Sweep only these classes"
1355
+ },
1356
+ {
1357
+ flag: "--skip",
1358
+ values: "<class,...>",
1359
+ description: "Skip these classes"
1360
+ },
1361
+ {
1362
+ flag: "--dry-run",
1363
+ description: "Print the plan and do not broadcast"
1364
+ }
1365
+ ]
1338
1366
  }
1339
1367
  ]
1340
1368
  },
@@ -3964,11 +3992,146 @@ var init_storage = __esm(() => {
3964
3992
  init_output();
3965
3993
  });
3966
3994
 
3995
+ // src/commands/sweep-classes.ts
3996
+ function parseSweepClasses(raw, flag) {
3997
+ if (!raw)
3998
+ return new Set;
3999
+ const selected = new Set;
4000
+ for (const token of raw.split(",").map((t) => t.trim().toLowerCase())) {
4001
+ if (!token)
4002
+ continue;
4003
+ const mapped = ALIASES[token];
4004
+ if (!mapped) {
4005
+ throw new Error(`Unknown ${flag} class '${token}'. Use: ${SWEEP_CLASSES.join(", ")}`);
4006
+ }
4007
+ selected.add(mapped);
4008
+ }
4009
+ return selected;
4010
+ }
4011
+ function selectedSweepClasses(only, skip) {
4012
+ if (only && skip) {
4013
+ throw new Error("--only and --skip are mutually exclusive");
4014
+ }
4015
+ if (only) {
4016
+ const selected2 = parseSweepClasses(only, "--only");
4017
+ if (selected2.size === 0) {
4018
+ throw new Error("--only requires at least one class");
4019
+ }
4020
+ return selected2;
4021
+ }
4022
+ const selected = new Set(SWEEP_CLASSES);
4023
+ if (skip) {
4024
+ for (const cls of parseSweepClasses(skip, "--skip"))
4025
+ selected.delete(cls);
4026
+ }
4027
+ return selected;
4028
+ }
4029
+ var SWEEP_CLASSES, ALIASES;
4030
+ var init_sweep_classes = __esm(() => {
4031
+ SWEEP_CLASSES = [
4032
+ "bsv",
4033
+ "ordinals",
4034
+ "opns",
4035
+ "bsv20",
4036
+ "bsv21"
4037
+ ];
4038
+ ALIASES = {
4039
+ bsv: "bsv",
4040
+ funding: "bsv",
4041
+ ordinals: "ordinals",
4042
+ ord: "ordinals",
4043
+ opns: "opns",
4044
+ names: "opns",
4045
+ bsv20: "bsv20",
4046
+ bsv21: "bsv21"
4047
+ };
4048
+ });
4049
+
4050
+ // src/commands/sweep-plan.ts
4051
+ import {
4052
+ groupBsv20Tokens,
4053
+ isListedOutput
4054
+ } from "@1sat/actions";
4055
+ function listedCount(outputs) {
4056
+ return outputs.filter((o) => isListedOutput(o)).length;
4057
+ }
4058
+ function buildSweepPlan(scan, classes) {
4059
+ const want = (cls) => classes.has(cls);
4060
+ const groupedBsv20 = groupBsv20Tokens(scan.bsv20Tokens);
4061
+ const parsedOutpoints = new Set(groupedBsv20.flatMap((g) => g.outputs.map((o) => o.outpoint)));
4062
+ const transferableBsv21 = scan.bsv21Tokens.filter((t) => t.outputs.length > 0);
4063
+ const skippedBsv21 = scan.bsv21Tokens.filter((t) => t.outputs.length === 0);
4064
+ return {
4065
+ classes: SWEEP_CLASSES.filter((c) => classes.has(c)),
4066
+ bsv: want("bsv") ? { count: scan.funding.length, sats: scan.totalFundingSats } : { count: 0, sats: 0 },
4067
+ ordinals: want("ordinals") ? {
4068
+ count: scan.ordinals.length,
4069
+ listed: listedCount(scan.ordinals)
4070
+ } : { count: 0, listed: 0 },
4071
+ opns: want("opns") ? {
4072
+ count: scan.opnsNames.length,
4073
+ listed: listedCount(scan.opnsNames)
4074
+ } : { count: 0, listed: 0 },
4075
+ bsv20: want("bsv20") ? groupedBsv20.map((g) => ({
4076
+ tick: g.tick,
4077
+ amount: g.totalAmount.toString(),
4078
+ count: g.outputs.length,
4079
+ listed: listedCount(g.outputs)
4080
+ })) : [],
4081
+ bsv21: want("bsv21") ? transferableBsv21.map((t) => ({
4082
+ tokenId: t.tokenId,
4083
+ symbol: t.symbol,
4084
+ amount: t.totalAmount.toString(),
4085
+ count: t.outputs.length,
4086
+ listed: listedCount(t.outputs),
4087
+ active: t.isActive
4088
+ })) : [],
4089
+ leftover: {
4090
+ locked: scan.locked.length,
4091
+ run: scan.run.length,
4092
+ inactiveBsv21: skippedBsv21.reduce((n, t) => n + t.outputs.length, 0),
4093
+ unparsedBsv20: scan.bsv20Tokens.filter((o) => !parsedOutpoints.has(o.outpoint)).length
4094
+ }
4095
+ };
4096
+ }
4097
+ function planHasWork(plan) {
4098
+ return plan.bsv.count > 0 || plan.ordinals.count > 0 || plan.opns.count > 0 || plan.bsv20.length > 0 || plan.bsv21.length > 0;
4099
+ }
4100
+ function describePlan(plan) {
4101
+ const parts = [];
4102
+ if (plan.bsv.count)
4103
+ parts.push(`${plan.bsv.sats} sats (${plan.bsv.count} UTXOs)`);
4104
+ if (plan.ordinals.count) {
4105
+ const listed = plan.ordinals.listed ? `, ${plan.ordinals.listed} listed` : "";
4106
+ parts.push(`${plan.ordinals.count} ordinal(s)${listed}`);
4107
+ }
4108
+ if (plan.opns.count) {
4109
+ const listed = plan.opns.listed ? `, ${plan.opns.listed} listed` : "";
4110
+ parts.push(`${plan.opns.count} OpNS name(s)${listed}`);
4111
+ }
4112
+ for (const t of plan.bsv20) {
4113
+ const listed = t.listed ? `, ${t.listed} listed` : "";
4114
+ parts.push(`${t.amount} ${t.tick} (${t.count} UTXOs${listed})`);
4115
+ }
4116
+ for (const t of plan.bsv21) {
4117
+ const listed = t.listed ? `, ${t.listed} listed` : "";
4118
+ parts.push(`${t.amount} ${t.symbol ?? t.tokenId.slice(0, 12)} (${t.count} UTXOs${listed})`);
4119
+ }
4120
+ return parts;
4121
+ }
4122
+ var init_sweep_plan = __esm(() => {
4123
+ init_sweep_classes();
4124
+ });
4125
+
3967
4126
  // src/commands/sweep.ts
3968
4127
  import {
4128
+ SWEEP_BATCH_SIZE,
4129
+ bsv21SweepBatches,
4130
+ groupBsv20Tokens as groupBsv20Tokens2,
3969
4131
  prepareSweepInputs,
3970
4132
  scanAddress,
3971
4133
  sweepBsv,
4134
+ sweepBsv20,
3972
4135
  sweepBsv21,
3973
4136
  sweepOrdinals
3974
4137
  } from "@1sat/actions";
@@ -3988,176 +4151,253 @@ async function handleSweepCommand(args, opts) {
3988
4151
  }
3989
4152
  }
3990
4153
  }
3991
- async function sweepScan(args, opts) {
4154
+ function loadSweepKey(args) {
3992
4155
  const wif = extractFlag(args, "--wif");
3993
4156
  if (!wif)
3994
4157
  fatal("Missing --wif <private-key>");
3995
- let address;
3996
4158
  try {
3997
4159
  const pk = PrivateKey4.fromWif(wif);
3998
- address = pk.toPublicKey().toAddress();
4160
+ return { wif, address: pk.toPublicKey().toAddress() };
3999
4161
  } catch {
4000
4162
  fatal("Invalid WIF private key");
4001
4163
  }
4164
+ }
4165
+ function loadClasses(args) {
4166
+ try {
4167
+ return selectedSweepClasses(extractFlag(args, "--only"), extractFlag(args, "--skip"));
4168
+ } catch (error) {
4169
+ fatal(error instanceof Error ? error.message : String(error));
4170
+ }
4171
+ }
4172
+ function leftoverLines(plan) {
4173
+ const lines = [];
4174
+ if (plan.leftover.locked)
4175
+ lines.push(`${plan.leftover.locked} locked output(s) (not swept)`);
4176
+ if (plan.leftover.run)
4177
+ lines.push(`${plan.leftover.run} RUN token output(s) (not sweepable)`);
4178
+ if (plan.leftover.inactiveBsv21)
4179
+ lines.push(`${plan.leftover.inactiveBsv21} BSV-21 UTXO(s) with no parseable amount`);
4180
+ if (plan.leftover.unparsedBsv20)
4181
+ lines.push(`${plan.leftover.unparsedBsv20} BSV-20 UTXO(s) with no parseable tick/amt`);
4182
+ return lines;
4183
+ }
4184
+ function printInventory(address, plan, opts, mode) {
4185
+ if (opts.json) {
4186
+ output({
4187
+ address,
4188
+ mode,
4189
+ classes: plan.classes,
4190
+ wouldSweep: {
4191
+ bsv: plan.bsv,
4192
+ ordinals: plan.ordinals,
4193
+ opns: plan.opns,
4194
+ bsv20: plan.bsv20,
4195
+ bsv21: plan.bsv21
4196
+ },
4197
+ leftover: plan.leftover
4198
+ }, opts);
4199
+ return;
4200
+ }
4201
+ const title = mode === "dry-run" ? "Dry run" : "Scan";
4202
+ console.log(`
4203
+ ${title} for ${formatValue(address)}
4204
+ `);
4205
+ console.log(` ${formatLabel("Classes:")} ${formatValue(plan.classes.join(", ") || "(none)")}`);
4206
+ console.log(`
4207
+ ${formatLabel("Funding UTXOs:")} ${plan.bsv.count}`);
4208
+ if (plan.bsv.count > 0) {
4209
+ console.log(` ${formatLabel("Total funding:")} ${formatValue(plan.bsv.sats)} satoshis`);
4210
+ }
4211
+ console.log(`
4212
+ ${formatLabel("Ordinal UTXOs:")} ${plan.ordinals.count}`);
4213
+ if (plan.ordinals.listed)
4214
+ console.log(` ${formatLabel("Listed:")} ${plan.ordinals.listed}`);
4215
+ console.log(`
4216
+ ${formatLabel("OpNS names:")} ${plan.opns.count}`);
4217
+ if (plan.opns.listed)
4218
+ console.log(` ${formatLabel("Listed:")} ${plan.opns.listed}`);
4219
+ console.log(`
4220
+ ${formatLabel("BSV-20:")} ${plan.bsv20.length} ticker(s)`);
4221
+ for (const t of plan.bsv20) {
4222
+ const listed = t.listed ? ` ${formatLabel(`listed ${t.listed}`)}` : "";
4223
+ console.log(` ${formatValue(t.tick)} ${formatLabel("amount:")} ${formatValue(t.amount)} ${formatLabel("UTXOs:")} ${t.count}${listed}`);
4224
+ }
4225
+ console.log(`
4226
+ ${formatLabel("BSV-21:")} ${plan.bsv21.length} token(s)`);
4227
+ for (const t of plan.bsv21) {
4228
+ const listed = t.listed ? ` ${formatLabel(`listed ${t.listed}`)}` : "";
4229
+ console.log(` ${formatValue(t.symbol ?? t.tokenId.slice(0, 12))} ${formatLabel("amount:")} ${formatValue(t.amount)} ${formatLabel("UTXOs:")} ${t.count}${listed}`);
4230
+ }
4231
+ const leftover = leftoverLines(plan);
4232
+ if (leftover.length) {
4233
+ console.log(`
4234
+ ${formatLabel("Leftover:")}`);
4235
+ for (const line of leftover)
4236
+ console.log(` ${formatWarning(line)}`);
4237
+ }
4238
+ const transferable = plan.bsv.count + plan.ordinals.count + plan.opns.count + plan.bsv20.reduce((n, t) => n + t.count, 0) + plan.bsv21.reduce((n, t) => n + t.count, 0);
4239
+ console.log(`
4240
+ ${transferable} transferable UTXO(s) in selected classes.`);
4241
+ }
4242
+ async function sweepScan(args, opts) {
4243
+ const { address } = loadSweepKey(args);
4244
+ const classes = loadClasses(args);
4002
4245
  const privateKey = await loadKey();
4003
4246
  const { ctx, destroy } = await loadContext(privateKey, {
4004
4247
  chain: opts.chain
4005
4248
  });
4006
4249
  try {
4007
- if (!ctx.services) {
4250
+ if (!ctx.services)
4008
4251
  fatal("Services required for sweep scan");
4009
- }
4010
- const result = await scanAddress(ctx.services, address);
4011
- if (opts.json) {
4012
- output(result, opts);
4013
- return;
4014
- }
4015
- console.log(`
4016
- Scan results for ${formatValue(address)}:
4017
- `);
4018
- console.log(` ${formatLabel("Funding UTXOs:")} ${result.funding.length}`);
4019
- if (result.funding.length > 0) {
4020
- console.log(` ${formatLabel("Total funding:")} ${formatValue(result.totalFundingSats)} satoshis`);
4021
- for (const f of result.funding) {
4022
- console.log(` ${formatValue(f.outpoint)} ${formatLabel(`${f.satoshis ?? 0} sats`)}`);
4023
- }
4024
- }
4025
- console.log(`
4026
- ${formatLabel("Ordinal UTXOs:")} ${result.ordinals.length}`);
4027
- if (result.ordinals.length > 0) {
4028
- for (const o of result.ordinals) {
4029
- console.log(` ${formatValue(o.outpoint)}`);
4030
- }
4031
- }
4032
- console.log(`
4033
- ${formatLabel("BSV-21 Tokens:")} ${result.bsv21Tokens.length}`);
4034
- if (result.bsv21Tokens.length > 0) {
4035
- for (const t of result.bsv21Tokens) {
4036
- console.log(` ${formatValue(t.symbol ?? t.tokenId.slice(0, 12))} ${formatLabel("amount:")} ${formatValue(t.totalAmount.toString())} ${formatLabel("UTXOs:")} ${t.outputs.length} ${formatLabel("dec:")} ${t.decimals}`);
4037
- }
4038
- }
4039
- console.log(`
4040
- ${formatLabel("RUN Tokens:")} ${result.run.length}`);
4041
- if (result.run.length > 0) {
4042
- const runSats = result.run.reduce((sum, r) => sum + (r.satoshis ?? 0), 0);
4043
- console.log(` ${formatLabel("Total locked:")} ${formatValue(runSats)} satoshis (not sweepable)`);
4044
- for (const r of result.run) {
4045
- console.log(` ${formatValue(r.outpoint)} ${formatLabel(`${r.satoshis ?? 0} sats`)}`);
4046
- }
4047
- }
4048
- const total = result.funding.length + result.ordinals.length + result.bsv21Tokens.reduce((n, t) => n + t.outputs.length, 0);
4049
- console.log(`
4050
- ${total} sweepable UTXO(s) found.`);
4051
- if (result.run.length > 0) {
4052
- console.log(` ${result.run.length} RUN token output(s) excluded.`);
4053
- }
4252
+ const scan = await scanAddress(ctx.services, address);
4253
+ printInventory(address, buildSweepPlan(scan, classes), opts, "scan");
4054
4254
  } finally {
4055
4255
  await destroy();
4056
4256
  }
4057
4257
  }
4058
4258
  async function sweepImport(args, opts) {
4059
- const wif = extractFlag(args, "--wif");
4060
- if (!wif)
4061
- fatal("Missing --wif <private-key>");
4062
- let address;
4063
- let sweepKey;
4064
- try {
4065
- sweepKey = PrivateKey4.fromWif(wif);
4066
- address = sweepKey.toPublicKey().toAddress();
4067
- } catch {
4068
- fatal("Invalid WIF private key");
4069
- }
4259
+ const { wif, address } = loadSweepKey(args);
4260
+ const classes = loadClasses(args);
4261
+ const dryRun = hasFlag(args, "--dry-run");
4262
+ const sweepKey = PrivateKey4.fromWif(wif);
4070
4263
  const privateKey = await loadKey();
4071
4264
  const { ctx, destroy } = await loadContext(privateKey, {
4072
4265
  chain: opts.chain
4073
4266
  });
4267
+ let failed = false;
4074
4268
  try {
4075
- if (!ctx.services) {
4269
+ if (!ctx.services)
4076
4270
  fatal("Services required for sweep import");
4077
- }
4078
4271
  const scan = await scanAddress(ctx.services, address);
4079
- const hasFunding = scan.funding.length > 0;
4080
- const hasOrdinals = scan.ordinals.length > 0;
4081
- const hasTokens = scan.bsv21Tokens.some((t) => t.isActive && t.outputs.length > 0);
4082
- if (!hasFunding && !hasOrdinals && !hasTokens) {
4083
- if (scan.run.length > 0) {
4084
- fatal(`No sweepable UTXOs found at ${address} (${scan.run.length} RUN token output(s) excluded)`);
4085
- }
4086
- fatal(`No UTXOs found at ${address}`);
4087
- }
4088
- const parts = [];
4089
- if (hasFunding)
4090
- parts.push(`${scan.totalFundingSats} sats (${scan.funding.length} UTXOs)`);
4091
- if (hasOrdinals)
4092
- parts.push(`${scan.ordinals.length} ordinal(s)`);
4093
- if (hasTokens) {
4094
- for (const t of scan.bsv21Tokens) {
4095
- if (t.isActive && t.outputs.length > 0) {
4096
- parts.push(`${t.totalAmount} ${t.symbol ?? t.tokenId.slice(0, 12)} token(s)`);
4097
- }
4098
- }
4272
+ const plan = buildSweepPlan(scan, classes);
4273
+ if (dryRun) {
4274
+ printInventory(address, plan, opts, "dry-run");
4275
+ return;
4099
4276
  }
4100
- if (scan.run.length > 0) {
4101
- parts.push(`${scan.run.length} RUN token output(s) excluded`);
4277
+ if (!planHasWork(plan)) {
4278
+ const leftover = leftoverLines(plan);
4279
+ const extra = leftover.length ? ` (${leftover.join("; ")})` : "";
4280
+ fatal(`No transferable UTXOs found at ${address}${extra}`);
4102
4281
  }
4282
+ const parts = describePlan(plan);
4283
+ for (const line of leftoverLines(plan))
4284
+ parts.push(line);
4103
4285
  if (!opts.yes) {
4104
4286
  const ok = await confirm7({
4105
4287
  message: `Sweep ${parts.join(", ")} from ${address}?`
4106
4288
  });
4107
- if (isCancel8(ok) || !ok) {
4289
+ if (isCancel8(ok) || !ok)
4108
4290
  fatal("Sweep cancelled.");
4109
- }
4110
4291
  }
4111
4292
  const txids = [];
4112
- if (hasFunding) {
4113
- const inputs = await prepareSweepInputs(ctx, scan.funding);
4114
- const keys = inputs.map(() => sweepKey);
4115
- const result = await sweepBsv.execute(ctx, { inputs, keys });
4116
- if (result.error) {
4117
- fatal(`BSV sweep failed: ${result.error}`);
4293
+ const errors = [];
4294
+ const run = async (label, fn) => {
4295
+ try {
4296
+ const result = await fn();
4297
+ if (result.error) {
4298
+ errors.push(`${label}: ${result.error}`);
4299
+ return false;
4300
+ }
4301
+ if (result.txid)
4302
+ txids.push(result.txid);
4303
+ return true;
4304
+ } catch (error) {
4305
+ errors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
4306
+ return false;
4307
+ }
4308
+ };
4309
+ const keysFor = (inputs) => inputs.map(() => sweepKey);
4310
+ const runOrdinalBatches = async (label, outputs) => {
4311
+ for (let offset = 0;offset < outputs.length; offset += SWEEP_BATCH_SIZE) {
4312
+ const batch = outputs.slice(offset, offset + SWEEP_BATCH_SIZE);
4313
+ const ok = await run(outputs.length <= SWEEP_BATCH_SIZE ? label : `${label} (${offset + 1}–${offset + batch.length} of ${outputs.length})`, async () => {
4314
+ const inputs = await prepareSweepInputs(ctx, batch);
4315
+ return sweepOrdinals.execute(ctx, {
4316
+ inputs,
4317
+ keys: keysFor(inputs)
4318
+ });
4319
+ });
4320
+ if (!ok)
4321
+ break;
4118
4322
  }
4119
- if (result.txid)
4120
- txids.push(result.txid);
4121
- }
4122
- if (hasOrdinals) {
4123
- const inputs = await prepareSweepInputs(ctx, scan.ordinals);
4124
- const keys = inputs.map(() => sweepKey);
4125
- const result = await sweepOrdinals.execute(ctx, { inputs, keys });
4126
- if (result.error) {
4127
- fatal(`Ordinals sweep failed: ${result.error}`);
4323
+ };
4324
+ if (plan.bsv.count) {
4325
+ await run("BSV", async () => {
4326
+ const inputs = await prepareSweepInputs(ctx, scan.funding);
4327
+ return sweepBsv.execute(ctx, {
4328
+ inputs,
4329
+ keys: keysFor(inputs)
4330
+ });
4331
+ });
4332
+ }
4333
+ if (plan.ordinals.count) {
4334
+ await runOrdinalBatches("Ordinals", scan.ordinals);
4335
+ }
4336
+ if (plan.opns.count) {
4337
+ await runOrdinalBatches("OpNS", scan.opnsNames);
4338
+ }
4339
+ if (plan.bsv20.length) {
4340
+ const grouped = groupBsv20Tokens2(scan.bsv20Tokens);
4341
+ for (const token of grouped) {
4342
+ await run(`BSV-20 ${token.tick}`, async () => {
4343
+ const base = await prepareSweepInputs(ctx, token.outputs);
4344
+ const inputs = base.map((b) => ({
4345
+ ...b,
4346
+ tick: token.tick,
4347
+ amount: token.amounts.get(b.outpoint) ?? "0"
4348
+ }));
4349
+ return sweepBsv20.execute(ctx, {
4350
+ inputs,
4351
+ keys: inputs.map(() => sweepKey)
4352
+ });
4353
+ });
4128
4354
  }
4129
- if (result.txid)
4130
- txids.push(result.txid);
4131
4355
  }
4132
- if (hasTokens) {
4356
+ if (plan.bsv21.length) {
4133
4357
  for (const token of scan.bsv21Tokens) {
4134
- if (!token.isActive || token.outputs.length === 0)
4358
+ if (token.outputs.length === 0)
4135
4359
  continue;
4136
- const base = await prepareSweepInputs(ctx, token.outputs);
4137
- const inputs = base.map((b) => ({
4138
- ...b,
4139
- tokenId: token.tokenId,
4140
- amount: token.amounts.get(b.outpoint) ?? "0"
4141
- }));
4142
- const keys = inputs.map(() => sweepKey);
4143
- const result = await sweepBsv21.execute(ctx, { inputs, keys });
4144
- if (result.error) {
4145
- fatal(`Token sweep failed (${token.symbol ?? token.tokenId.slice(0, 12)}): ${result.error}`);
4360
+ const name = token.symbol ?? token.tokenId.slice(0, 12);
4361
+ for (const batch of bsv21SweepBatches(token.outputs)) {
4362
+ await run(`BSV-21 ${name}`, async () => {
4363
+ const base = await prepareSweepInputs(ctx, batch);
4364
+ const inputs = base.map((b) => ({
4365
+ ...b,
4366
+ tokenId: token.tokenId,
4367
+ amount: token.amounts.get(b.outpoint) ?? "0"
4368
+ }));
4369
+ return sweepBsv21.execute(ctx, {
4370
+ inputs,
4371
+ keys: inputs.map(() => sweepKey)
4372
+ });
4373
+ });
4146
4374
  }
4147
- if (result.txid)
4148
- txids.push(result.txid);
4149
4375
  }
4150
4376
  }
4151
- output(opts.json ? { txids, swept: parts } : `Sweep complete. ${txids.length} transaction(s): ${txids.join(", ")}`, opts);
4377
+ if (opts.json) {
4378
+ output({ txids, errors, swept: parts }, opts);
4379
+ } else if (errors.length) {
4380
+ console.log(`Sweep finished with errors. ${txids.length} transaction(s): ${txids.join(", ") || "(none)"}`);
4381
+ for (const err of errors)
4382
+ console.error(formatWarning(err));
4383
+ } else {
4384
+ output(`Sweep complete. ${txids.length} transaction(s): ${txids.join(", ")}`, opts);
4385
+ }
4386
+ if (errors.length)
4387
+ failed = true;
4152
4388
  } finally {
4153
4389
  await destroy();
4154
4390
  }
4391
+ if (failed)
4392
+ process.exit(1);
4155
4393
  }
4156
4394
  var init_sweep = __esm(() => {
4157
4395
  init_context();
4158
4396
  init_help();
4159
4397
  init_keys();
4160
4398
  init_output();
4399
+ init_sweep_classes();
4400
+ init_sweep_plan();
4161
4401
  });
4162
4402
 
4163
4403
  // src/commands/tokens.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1sat/cli",
3
- "version": "0.0.114",
3
+ "version": "0.0.115",
4
4
  "description": "CLI for 1Sat Ordinals SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -27,9 +27,9 @@
27
27
  ],
28
28
  "license": "MIT",
29
29
  "dependencies": {
30
- "@1sat/actions": "0.0.216",
30
+ "@1sat/actions": "0.0.217",
31
31
  "@1sat/client": "0.0.53",
32
- "@1sat/types": "0.0.45",
32
+ "@1sat/types": "0.0.46",
33
33
  "@1sat/wallet-node": "0.0.75",
34
34
  "@1sat/wallet-server": "0.0.51",
35
35
  "@bopen-io/messagebox-server": "^1.3.2",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Transferable sweep classes. Locked and RUN outputs are leftover, not classes.
3
+ */
4
+ export const SWEEP_CLASSES = [
5
+ 'bsv',
6
+ 'ordinals',
7
+ 'opns',
8
+ 'bsv20',
9
+ 'bsv21',
10
+ ] as const
11
+
12
+ export type SweepClass = (typeof SWEEP_CLASSES)[number]
13
+
14
+ const ALIASES: Record<string, SweepClass> = {
15
+ bsv: 'bsv',
16
+ funding: 'bsv',
17
+ ordinals: 'ordinals',
18
+ ord: 'ordinals',
19
+ opns: 'opns',
20
+ names: 'opns',
21
+ bsv20: 'bsv20',
22
+ bsv21: 'bsv21',
23
+ }
24
+
25
+ export function parseSweepClasses(
26
+ raw: string | undefined,
27
+ flag: string,
28
+ ): Set<SweepClass> {
29
+ if (!raw) return new Set()
30
+ const selected = new Set<SweepClass>()
31
+ for (const token of raw.split(',').map((t) => t.trim().toLowerCase())) {
32
+ if (!token) continue
33
+ const mapped = ALIASES[token]
34
+ if (!mapped) {
35
+ throw new Error(
36
+ `Unknown ${flag} class '${token}'. Use: ${SWEEP_CLASSES.join(', ')}`,
37
+ )
38
+ }
39
+ selected.add(mapped)
40
+ }
41
+ return selected
42
+ }
43
+
44
+ /** Default is every transferable class. `--only` and `--skip` are exclusive. */
45
+ export function selectedSweepClasses(
46
+ only: string | undefined,
47
+ skip: string | undefined,
48
+ ): Set<SweepClass> {
49
+ if (only && skip) {
50
+ throw new Error('--only and --skip are mutually exclusive')
51
+ }
52
+ if (only) {
53
+ const selected = parseSweepClasses(only, '--only')
54
+ if (selected.size === 0) {
55
+ throw new Error('--only requires at least one class')
56
+ }
57
+ return selected
58
+ }
59
+ const selected = new Set<SweepClass>(SWEEP_CLASSES)
60
+ if (skip) {
61
+ for (const cls of parseSweepClasses(skip, '--skip')) selected.delete(cls)
62
+ }
63
+ return selected
64
+ }
@@ -0,0 +1,134 @@
1
+ import {
2
+ type ScanResult,
3
+ groupBsv20Tokens,
4
+ isListedOutput,
5
+ } from '@1sat/actions'
6
+ import { SWEEP_CLASSES, type SweepClass } from './sweep-classes.js'
7
+
8
+ function listedCount(
9
+ outputs: Array<{ events?: string[]; data?: Record<string, unknown> }>,
10
+ ): number {
11
+ return outputs.filter((o) => isListedOutput(o)).length
12
+ }
13
+
14
+ export type SweepPlan = {
15
+ classes: SweepClass[]
16
+ bsv: { count: number; sats: number }
17
+ ordinals: { count: number; listed: number }
18
+ opns: { count: number; listed: number }
19
+ bsv20: Array<{
20
+ tick: string
21
+ amount: string
22
+ count: number
23
+ listed: number
24
+ }>
25
+ bsv21: Array<{
26
+ tokenId: string
27
+ symbol?: string
28
+ amount: string
29
+ count: number
30
+ listed: number
31
+ active: boolean
32
+ }>
33
+ leftover: {
34
+ locked: number
35
+ run: number
36
+ inactiveBsv21: number
37
+ unparsedBsv20: number
38
+ }
39
+ }
40
+
41
+ export function buildSweepPlan(
42
+ scan: ScanResult,
43
+ classes: Set<SweepClass>,
44
+ ): SweepPlan {
45
+ const want = (cls: SweepClass) => classes.has(cls)
46
+ const groupedBsv20 = groupBsv20Tokens(scan.bsv20Tokens)
47
+ const parsedOutpoints = new Set(
48
+ groupedBsv20.flatMap((g) => g.outputs.map((o) => o.outpoint)),
49
+ )
50
+ const transferableBsv21 = scan.bsv21Tokens.filter((t) => t.outputs.length > 0)
51
+ const skippedBsv21 = scan.bsv21Tokens.filter((t) => t.outputs.length === 0)
52
+
53
+ return {
54
+ classes: SWEEP_CLASSES.filter((c) => classes.has(c)),
55
+ bsv: want('bsv')
56
+ ? { count: scan.funding.length, sats: scan.totalFundingSats }
57
+ : { count: 0, sats: 0 },
58
+ ordinals: want('ordinals')
59
+ ? {
60
+ count: scan.ordinals.length,
61
+ listed: listedCount(scan.ordinals),
62
+ }
63
+ : { count: 0, listed: 0 },
64
+ opns: want('opns')
65
+ ? {
66
+ count: scan.opnsNames.length,
67
+ listed: listedCount(scan.opnsNames),
68
+ }
69
+ : { count: 0, listed: 0 },
70
+ bsv20: want('bsv20')
71
+ ? groupedBsv20.map((g) => ({
72
+ tick: g.tick,
73
+ amount: g.totalAmount.toString(),
74
+ count: g.outputs.length,
75
+ listed: listedCount(g.outputs),
76
+ }))
77
+ : [],
78
+ bsv21: want('bsv21')
79
+ ? transferableBsv21.map((t) => ({
80
+ tokenId: t.tokenId,
81
+ symbol: t.symbol,
82
+ amount: t.totalAmount.toString(),
83
+ count: t.outputs.length,
84
+ listed: listedCount(t.outputs),
85
+ active: t.isActive,
86
+ }))
87
+ : [],
88
+ leftover: {
89
+ locked: scan.locked.length,
90
+ run: scan.run.length,
91
+ inactiveBsv21: skippedBsv21.reduce((n, t) => n + t.outputs.length, 0),
92
+ unparsedBsv20: scan.bsv20Tokens.filter(
93
+ (o) => !parsedOutpoints.has(o.outpoint),
94
+ ).length,
95
+ },
96
+ }
97
+ }
98
+
99
+ export function planHasWork(plan: SweepPlan): boolean {
100
+ return (
101
+ plan.bsv.count > 0 ||
102
+ plan.ordinals.count > 0 ||
103
+ plan.opns.count > 0 ||
104
+ plan.bsv20.length > 0 ||
105
+ plan.bsv21.length > 0
106
+ )
107
+ }
108
+
109
+ export function describePlan(plan: SweepPlan): string[] {
110
+ const parts: string[] = []
111
+ if (plan.bsv.count)
112
+ parts.push(`${plan.bsv.sats} sats (${plan.bsv.count} UTXOs)`)
113
+ if (plan.ordinals.count) {
114
+ const listed = plan.ordinals.listed
115
+ ? `, ${plan.ordinals.listed} listed`
116
+ : ''
117
+ parts.push(`${plan.ordinals.count} ordinal(s)${listed}`)
118
+ }
119
+ if (plan.opns.count) {
120
+ const listed = plan.opns.listed ? `, ${plan.opns.listed} listed` : ''
121
+ parts.push(`${plan.opns.count} OpNS name(s)${listed}`)
122
+ }
123
+ for (const t of plan.bsv20) {
124
+ const listed = t.listed ? `, ${t.listed} listed` : ''
125
+ parts.push(`${t.amount} ${t.tick} (${t.count} UTXOs${listed})`)
126
+ }
127
+ for (const t of plan.bsv21) {
128
+ const listed = t.listed ? `, ${t.listed} listed` : ''
129
+ parts.push(
130
+ `${t.amount} ${t.symbol ?? t.tokenId.slice(0, 12)} (${t.count} UTXOs${listed})`,
131
+ )
132
+ }
133
+ return parts
134
+ }
@@ -5,20 +5,37 @@
5
5
  */
6
6
 
7
7
  import {
8
+ SWEEP_BATCH_SIZE,
9
+ bsv21SweepBatches,
10
+ groupBsv20Tokens,
8
11
  prepareSweepInputs,
9
12
  scanAddress,
10
13
  sweepBsv,
14
+ sweepBsv20,
11
15
  sweepBsv21,
12
16
  sweepOrdinals,
13
17
  } from '@1sat/actions'
14
18
  import { PrivateKey } from '@bsv/sdk'
15
19
  import { confirm, isCancel } from '@clack/prompts'
16
20
  import type { GlobalFlags } from '../args.js'
17
- import { extractFlag } from '../args.js'
21
+ import { extractFlag, hasFlag } from '../args.js'
18
22
  import { loadContext } from '../context.js'
19
23
  import { printCommandHelp } from '../help.js'
20
24
  import { loadKey } from '../keys.js'
21
- import { fatal, formatLabel, formatValue, output } from '../output.js'
25
+ import {
26
+ fatal,
27
+ formatLabel,
28
+ formatValue,
29
+ formatWarning,
30
+ output,
31
+ } from '../output.js'
32
+ import { type SweepClass, selectedSweepClasses } from './sweep-classes.js'
33
+ import {
34
+ type SweepPlan,
35
+ buildSweepPlan,
36
+ describePlan,
37
+ planHasWork,
38
+ } from './sweep-plan.js'
22
39
 
23
40
  export async function handleSweepCommand(
24
41
  args: string[],
@@ -39,217 +56,306 @@ export async function handleSweepCommand(
39
56
  }
40
57
  }
41
58
 
42
- async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
59
+ function loadSweepKey(args: string[]): { wif: string; address: string } {
43
60
  const wif = extractFlag(args, '--wif')
44
-
45
61
  if (!wif) fatal('Missing --wif <private-key>')
46
-
47
- let address: string
48
62
  try {
49
63
  const pk = PrivateKey.fromWif(wif)
50
- address = pk.toPublicKey().toAddress()
64
+ return { wif, address: pk.toPublicKey().toAddress() }
51
65
  } catch {
52
66
  fatal('Invalid WIF private key')
53
67
  }
68
+ }
54
69
 
55
- const privateKey = await loadKey()
56
- const { ctx, destroy } = await loadContext(privateKey, {
57
- chain: opts.chain,
58
- })
59
-
70
+ function loadClasses(args: string[]): Set<SweepClass> {
60
71
  try {
61
- if (!ctx.services) {
62
- fatal('Services required for sweep scan')
63
- }
64
-
65
- const result = await scanAddress(ctx.services, address)
72
+ return selectedSweepClasses(
73
+ extractFlag(args, '--only'),
74
+ extractFlag(args, '--skip'),
75
+ )
76
+ } catch (error) {
77
+ fatal(error instanceof Error ? error.message : String(error))
78
+ }
79
+ }
66
80
 
67
- if (opts.json) {
68
- output(result, opts)
69
- return
70
- }
81
+ function leftoverLines(plan: SweepPlan): string[] {
82
+ const lines: string[] = []
83
+ if (plan.leftover.locked)
84
+ lines.push(`${plan.leftover.locked} locked output(s) (not swept)`)
85
+ if (plan.leftover.run)
86
+ lines.push(`${plan.leftover.run} RUN token output(s) (not sweepable)`)
87
+ if (plan.leftover.inactiveBsv21)
88
+ lines.push(
89
+ `${plan.leftover.inactiveBsv21} BSV-21 UTXO(s) with no parseable amount`,
90
+ )
91
+ if (plan.leftover.unparsedBsv20)
92
+ lines.push(
93
+ `${plan.leftover.unparsedBsv20} BSV-20 UTXO(s) with no parseable tick/amt`,
94
+ )
95
+ return lines
96
+ }
71
97
 
72
- console.log(`\n Scan results for ${formatValue(address)}:\n`)
98
+ function printInventory(
99
+ address: string,
100
+ plan: SweepPlan,
101
+ opts: GlobalFlags,
102
+ mode: 'scan' | 'dry-run',
103
+ ): void {
104
+ if (opts.json) {
105
+ output(
106
+ {
107
+ address,
108
+ mode,
109
+ classes: plan.classes,
110
+ wouldSweep: {
111
+ bsv: plan.bsv,
112
+ ordinals: plan.ordinals,
113
+ opns: plan.opns,
114
+ bsv20: plan.bsv20,
115
+ bsv21: plan.bsv21,
116
+ },
117
+ leftover: plan.leftover,
118
+ },
119
+ opts,
120
+ )
121
+ return
122
+ }
73
123
 
74
- console.log(` ${formatLabel('Funding UTXOs:')} ${result.funding.length}`)
75
- if (result.funding.length > 0) {
76
- console.log(
77
- ` ${formatLabel('Total funding:')} ${formatValue(result.totalFundingSats)} satoshis`,
78
- )
79
- for (const f of result.funding) {
80
- console.log(
81
- ` ${formatValue(f.outpoint)} ${formatLabel(`${f.satoshis ?? 0} sats`)}`,
82
- )
83
- }
84
- }
124
+ const title = mode === 'dry-run' ? 'Dry run' : 'Scan'
125
+ console.log(`\n ${title} for ${formatValue(address)}\n`)
126
+ console.log(
127
+ ` ${formatLabel('Classes:')} ${formatValue(plan.classes.join(', ') || '(none)')}`,
128
+ )
85
129
 
130
+ console.log(`\n ${formatLabel('Funding UTXOs:')} ${plan.bsv.count}`)
131
+ if (plan.bsv.count > 0) {
86
132
  console.log(
87
- `\n ${formatLabel('Ordinal UTXOs:')} ${result.ordinals.length}`,
133
+ ` ${formatLabel('Total funding:')} ${formatValue(plan.bsv.sats)} satoshis`,
88
134
  )
89
- if (result.ordinals.length > 0) {
90
- for (const o of result.ordinals) {
91
- console.log(` ${formatValue(o.outpoint)}`)
92
- }
93
- }
135
+ }
136
+
137
+ console.log(`\n ${formatLabel('Ordinal UTXOs:')} ${plan.ordinals.count}`)
138
+ if (plan.ordinals.listed)
139
+ console.log(` ${formatLabel('Listed:')} ${plan.ordinals.listed}`)
94
140
 
141
+ console.log(`\n ${formatLabel('OpNS names:')} ${plan.opns.count}`)
142
+ if (plan.opns.listed)
143
+ console.log(` ${formatLabel('Listed:')} ${plan.opns.listed}`)
144
+
145
+ console.log(`\n ${formatLabel('BSV-20:')} ${plan.bsv20.length} ticker(s)`)
146
+ for (const t of plan.bsv20) {
147
+ const listed = t.listed ? ` ${formatLabel(`listed ${t.listed}`)}` : ''
95
148
  console.log(
96
- `\n ${formatLabel('BSV-21 Tokens:')} ${result.bsv21Tokens.length}`,
149
+ ` ${formatValue(t.tick)} ${formatLabel('amount:')} ${formatValue(t.amount)} ${formatLabel('UTXOs:')} ${t.count}${listed}`,
97
150
  )
98
- if (result.bsv21Tokens.length > 0) {
99
- for (const t of result.bsv21Tokens) {
100
- console.log(
101
- ` ${formatValue(t.symbol ?? t.tokenId.slice(0, 12))} ${formatLabel('amount:')} ${formatValue(t.totalAmount.toString())} ${formatLabel('UTXOs:')} ${t.outputs.length} ${formatLabel('dec:')} ${t.decimals}`,
102
- )
103
- }
104
- }
151
+ }
105
152
 
106
- console.log(`\n ${formatLabel('RUN Tokens:')} ${result.run.length}`)
107
- if (result.run.length > 0) {
108
- const runSats = result.run.reduce((sum, r) => sum + (r.satoshis ?? 0), 0)
109
- console.log(
110
- ` ${formatLabel('Total locked:')} ${formatValue(runSats)} satoshis (not sweepable)`,
111
- )
112
- for (const r of result.run) {
113
- console.log(
114
- ` ${formatValue(r.outpoint)} ${formatLabel(`${r.satoshis ?? 0} sats`)}`,
115
- )
116
- }
117
- }
153
+ console.log(`\n ${formatLabel('BSV-21:')} ${plan.bsv21.length} token(s)`)
154
+ for (const t of plan.bsv21) {
155
+ const listed = t.listed ? ` ${formatLabel(`listed ${t.listed}`)}` : ''
156
+ console.log(
157
+ ` ${formatValue(t.symbol ?? t.tokenId.slice(0, 12))} ${formatLabel('amount:')} ${formatValue(t.amount)} ${formatLabel('UTXOs:')} ${t.count}${listed}`,
158
+ )
159
+ }
118
160
 
119
- const total =
120
- result.funding.length +
121
- result.ordinals.length +
122
- result.bsv21Tokens.reduce((n, t) => n + t.outputs.length, 0)
123
- console.log(`\n ${total} sweepable UTXO(s) found.`)
124
- if (result.run.length > 0) {
125
- console.log(` ${result.run.length} RUN token output(s) excluded.`)
126
- }
127
- } finally {
128
- await destroy()
161
+ const leftover = leftoverLines(plan)
162
+ if (leftover.length) {
163
+ console.log(`\n ${formatLabel('Leftover:')}`)
164
+ for (const line of leftover) console.log(` ${formatWarning(line)}`)
129
165
  }
166
+
167
+ const transferable =
168
+ plan.bsv.count +
169
+ plan.ordinals.count +
170
+ plan.opns.count +
171
+ plan.bsv20.reduce((n, t) => n + t.count, 0) +
172
+ plan.bsv21.reduce((n, t) => n + t.count, 0)
173
+ console.log(`\n ${transferable} transferable UTXO(s) in selected classes.`)
130
174
  }
131
175
 
132
- async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
133
- const wif = extractFlag(args, '--wif')
176
+ async function sweepScan(args: string[], opts: GlobalFlags): Promise<void> {
177
+ const { address } = loadSweepKey(args)
178
+ const classes = loadClasses(args)
134
179
 
135
- if (!wif) fatal('Missing --wif <private-key>')
180
+ const privateKey = await loadKey()
181
+ const { ctx, destroy } = await loadContext(privateKey, {
182
+ chain: opts.chain,
183
+ })
136
184
 
137
- let address: string
138
- let sweepKey: PrivateKey
139
185
  try {
140
- sweepKey = PrivateKey.fromWif(wif)
141
- address = sweepKey.toPublicKey().toAddress()
142
- } catch {
143
- fatal('Invalid WIF private key')
186
+ if (!ctx.services) fatal('Services required for sweep scan')
187
+ const scan = await scanAddress(ctx.services, address)
188
+ printInventory(address, buildSweepPlan(scan, classes), opts, 'scan')
189
+ } finally {
190
+ await destroy()
144
191
  }
192
+ }
193
+
194
+ async function sweepImport(args: string[], opts: GlobalFlags): Promise<void> {
195
+ const { wif, address } = loadSweepKey(args)
196
+ const classes = loadClasses(args)
197
+ const dryRun = hasFlag(args, '--dry-run')
198
+ const sweepKey = PrivateKey.fromWif(wif)
145
199
 
146
200
  const privateKey = await loadKey()
147
201
  const { ctx, destroy } = await loadContext(privateKey, {
148
202
  chain: opts.chain,
149
203
  })
204
+ let failed = false
150
205
 
151
206
  try {
152
- if (!ctx.services) {
153
- fatal('Services required for sweep import')
154
- }
207
+ if (!ctx.services) fatal('Services required for sweep import')
155
208
 
156
209
  const scan = await scanAddress(ctx.services, address)
210
+ const plan = buildSweepPlan(scan, classes)
157
211
 
158
- const hasFunding = scan.funding.length > 0
159
- const hasOrdinals = scan.ordinals.length > 0
160
- const hasTokens = scan.bsv21Tokens.some(
161
- (t) => t.isActive && t.outputs.length > 0,
162
- )
163
-
164
- if (!hasFunding && !hasOrdinals && !hasTokens) {
165
- if (scan.run.length > 0) {
166
- fatal(
167
- `No sweepable UTXOs found at ${address} (${scan.run.length} RUN token output(s) excluded)`,
168
- )
169
- }
170
- fatal(`No UTXOs found at ${address}`)
212
+ if (dryRun) {
213
+ printInventory(address, plan, opts, 'dry-run')
214
+ return
171
215
  }
172
216
 
173
- const parts: string[] = []
174
- if (hasFunding)
175
- parts.push(`${scan.totalFundingSats} sats (${scan.funding.length} UTXOs)`)
176
- if (hasOrdinals) parts.push(`${scan.ordinals.length} ordinal(s)`)
177
- if (hasTokens) {
178
- for (const t of scan.bsv21Tokens) {
179
- if (t.isActive && t.outputs.length > 0) {
180
- parts.push(
181
- `${t.totalAmount} ${t.symbol ?? t.tokenId.slice(0, 12)} token(s)`,
182
- )
183
- }
184
- }
185
- }
186
- if (scan.run.length > 0) {
187
- parts.push(`${scan.run.length} RUN token output(s) excluded`)
217
+ if (!planHasWork(plan)) {
218
+ const leftover = leftoverLines(plan)
219
+ const extra = leftover.length ? ` (${leftover.join('; ')})` : ''
220
+ fatal(`No transferable UTXOs found at ${address}${extra}`)
188
221
  }
189
222
 
223
+ const parts = describePlan(plan)
224
+ for (const line of leftoverLines(plan)) parts.push(line)
225
+
190
226
  if (!opts.yes) {
191
227
  const ok = await confirm({
192
228
  message: `Sweep ${parts.join(', ')} from ${address}?`,
193
229
  })
194
- if (isCancel(ok) || !ok) {
195
- fatal('Sweep cancelled.')
196
- }
230
+ if (isCancel(ok) || !ok) fatal('Sweep cancelled.')
197
231
  }
198
232
 
199
233
  const txids: string[] = []
200
-
201
- // Sweep BSV funding UTXOs
202
- if (hasFunding) {
203
- const inputs = await prepareSweepInputs(ctx, scan.funding)
204
- const keys = inputs.map(() => sweepKey)
205
- const result = await sweepBsv.execute(ctx, { inputs, keys })
206
- if (result.error) {
207
- fatal(`BSV sweep failed: ${result.error}`)
234
+ const errors: string[] = []
235
+
236
+ const run = async (
237
+ label: string,
238
+ fn: () => Promise<{ txid?: string; error?: string }>,
239
+ ): Promise<boolean> => {
240
+ try {
241
+ const result = await fn()
242
+ if (result.error) {
243
+ errors.push(`${label}: ${result.error}`)
244
+ return false
245
+ }
246
+ if (result.txid) txids.push(result.txid)
247
+ return true
248
+ } catch (error) {
249
+ errors.push(
250
+ `${label}: ${error instanceof Error ? error.message : String(error)}`,
251
+ )
252
+ return false
208
253
  }
209
- if (result.txid) txids.push(result.txid)
210
254
  }
211
255
 
212
- // Sweep ordinals
213
- if (hasOrdinals) {
214
- const inputs = await prepareSweepInputs(ctx, scan.ordinals)
215
- const keys = inputs.map(() => sweepKey)
216
- const result = await sweepOrdinals.execute(ctx, { inputs, keys })
217
- if (result.error) {
218
- fatal(`Ordinals sweep failed: ${result.error}`)
256
+ const keysFor = (inputs: { outpoint: string }[]) =>
257
+ inputs.map(() => sweepKey)
258
+
259
+ const runOrdinalBatches = async (
260
+ label: string,
261
+ outputs: typeof scan.ordinals,
262
+ ): Promise<void> => {
263
+ for (
264
+ let offset = 0;
265
+ offset < outputs.length;
266
+ offset += SWEEP_BATCH_SIZE
267
+ ) {
268
+ const batch = outputs.slice(offset, offset + SWEEP_BATCH_SIZE)
269
+ const ok = await run(
270
+ outputs.length <= SWEEP_BATCH_SIZE
271
+ ? label
272
+ : `${label} (${offset + 1}–${offset + batch.length} of ${outputs.length})`,
273
+ async () => {
274
+ const inputs = await prepareSweepInputs(ctx, batch)
275
+ return sweepOrdinals.execute(ctx, {
276
+ inputs,
277
+ keys: keysFor(inputs),
278
+ })
279
+ },
280
+ )
281
+ if (!ok) break
219
282
  }
220
- if (result.txid) txids.push(result.txid)
221
283
  }
222
284
 
223
- // Sweep BSV-21 tokens (one sweep per active tokenId)
224
- if (hasTokens) {
225
- for (const token of scan.bsv21Tokens) {
226
- if (!token.isActive || token.outputs.length === 0) continue
285
+ if (plan.bsv.count) {
286
+ await run('BSV', async () => {
287
+ const inputs = await prepareSweepInputs(ctx, scan.funding)
288
+ return sweepBsv.execute(ctx, {
289
+ inputs,
290
+ keys: keysFor(inputs),
291
+ })
292
+ })
293
+ }
227
294
 
228
- const base = await prepareSweepInputs(ctx, token.outputs)
229
- const inputs = base.map((b) => ({
230
- ...b,
231
- tokenId: token.tokenId,
232
- amount: token.amounts.get(b.outpoint) ?? '0',
233
- }))
234
- const keys = inputs.map(() => sweepKey)
295
+ if (plan.ordinals.count) {
296
+ await runOrdinalBatches('Ordinals', scan.ordinals)
297
+ }
235
298
 
236
- const result = await sweepBsv21.execute(ctx, { inputs, keys })
237
- if (result.error) {
238
- fatal(
239
- `Token sweep failed (${token.symbol ?? token.tokenId.slice(0, 12)}): ${result.error}`,
240
- )
299
+ if (plan.opns.count) {
300
+ await runOrdinalBatches('OpNS', scan.opnsNames)
301
+ }
302
+
303
+ if (plan.bsv20.length) {
304
+ const grouped = groupBsv20Tokens(scan.bsv20Tokens)
305
+ for (const token of grouped) {
306
+ await run(`BSV-20 ${token.tick}`, async () => {
307
+ const base = await prepareSweepInputs(ctx, token.outputs)
308
+ const inputs = base.map((b) => ({
309
+ ...b,
310
+ tick: token.tick,
311
+ amount: token.amounts.get(b.outpoint) ?? '0',
312
+ }))
313
+ return sweepBsv20.execute(ctx, {
314
+ inputs,
315
+ keys: inputs.map(() => sweepKey),
316
+ })
317
+ })
318
+ }
319
+ }
320
+
321
+ if (plan.bsv21.length) {
322
+ for (const token of scan.bsv21Tokens) {
323
+ if (token.outputs.length === 0) continue
324
+ const name = token.symbol ?? token.tokenId.slice(0, 12)
325
+ for (const batch of bsv21SweepBatches(token.outputs)) {
326
+ await run(`BSV-21 ${name}`, async () => {
327
+ const base = await prepareSweepInputs(ctx, batch)
328
+ const inputs = base.map((b) => ({
329
+ ...b,
330
+ tokenId: token.tokenId,
331
+ amount: token.amounts.get(b.outpoint) ?? '0',
332
+ }))
333
+ return sweepBsv21.execute(ctx, {
334
+ inputs,
335
+ keys: inputs.map(() => sweepKey),
336
+ })
337
+ })
241
338
  }
242
- if (result.txid) txids.push(result.txid)
243
339
  }
244
340
  }
245
341
 
246
- output(
247
- opts.json
248
- ? { txids, swept: parts }
249
- : `Sweep complete. ${txids.length} transaction(s): ${txids.join(', ')}`,
250
- opts,
251
- )
342
+ if (opts.json) {
343
+ output({ txids, errors, swept: parts }, opts)
344
+ } else if (errors.length) {
345
+ console.log(
346
+ `Sweep finished with errors. ${txids.length} transaction(s): ${txids.join(', ') || '(none)'}`,
347
+ )
348
+ for (const err of errors) console.error(formatWarning(err))
349
+ } else {
350
+ output(
351
+ `Sweep complete. ${txids.length} transaction(s): ${txids.join(', ')}`,
352
+ opts,
353
+ )
354
+ }
355
+
356
+ if (errors.length) failed = true
252
357
  } finally {
253
358
  await destroy()
254
359
  }
360
+ if (failed) process.exit(1)
255
361
  }
package/src/help.ts CHANGED
@@ -698,13 +698,42 @@ export const COMMANDS: CommandSpec[] = [
698
698
  {
699
699
  name: 'scan',
700
700
  description:
701
- 'Scan an address for sweepable UTXOs (BSV, ordinals, BSV21)',
702
- args: [{ flag: '--wif', values: '<key>', required: true }],
701
+ 'Show every UTXO class at an address (BSV, ordinals, OpNS, BSV-20, BSV-21, leftover locks/RUN)',
702
+ args: [
703
+ { flag: '--wif', values: '<key>', required: true },
704
+ {
705
+ flag: '--only',
706
+ values: '<bsv,ordinals,opns,bsv20,bsv21>',
707
+ description: 'Limit the report to these classes',
708
+ },
709
+ {
710
+ flag: '--skip',
711
+ values: '<class,...>',
712
+ description: 'Omit classes from the report',
713
+ },
714
+ ],
703
715
  },
704
716
  {
705
717
  name: 'import',
706
- description: 'Sweep UTXOs from a WIF into the wallet',
707
- args: [{ flag: '--wif', values: '<key>', required: true }],
718
+ description:
719
+ 'Sweep transferable UTXOs from a WIF into the wallet. Default is all classes we can move to BRC-100 (including listed OrdLocks). Locks and RUN stay.',
720
+ args: [
721
+ { flag: '--wif', values: '<key>', required: true },
722
+ {
723
+ flag: '--only',
724
+ values: '<bsv,ordinals,opns,bsv20,bsv21>',
725
+ description: 'Sweep only these classes',
726
+ },
727
+ {
728
+ flag: '--skip',
729
+ values: '<class,...>',
730
+ description: 'Skip these classes',
731
+ },
732
+ {
733
+ flag: '--dry-run',
734
+ description: 'Print the plan and do not broadcast',
735
+ },
736
+ ],
708
737
  },
709
738
  ],
710
739
  },