@buildonspark/cli
Version:
Spark CLI
1,143 lines (1,135 loc) • 111 kB
JavaScript
#!/usr/bin/env node
import { IssuerSparkWallet } from "@buildonspark/issuer-sdk";
import { Network, SparkReadonlyClient, SparkSdkLogger, SparkWalletEvent, WalletConfig, constructFeeBumpTx, constructUnilateralExitFeeBumpPackages, decodeBech32mTokenIdentifier, decodeSparkAddress, encodeBech32mTokenIdentifier, encodeSparkAddress, getLatestDepositTxId, getNetwork, getNetworkFromBech32mTokenIdentifier, getP2TRScriptFromPublicKey, getP2WPKHAddressFromPublicKey, isEphemeralAnchorOutput, protoToNetwork, validateSparkInvoiceSignature } from "@buildonspark/spark-sdk";
import { InvoiceStatus, PreimageRequestRole, PreimageRequestStatus, TreeNode } from "@buildonspark/spark-sdk/proto/spark";
import { TokenTransactionStatus } from "@buildonspark/spark-sdk/proto/spark_token";
import { ExitSpeed, SparkWalletWebhookEventType } from "@buildonspark/spark-sdk/types";
import { LoggingLevel } from "@lightsparkdev/core";
import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
import { bytesToHex, bytesToNumberBE, hexToBytes } from "@noble/curves/utils";
import { ripemd160 } from "@noble/hashes/legacy";
import { sha256 } from "@noble/hashes/sha2";
import { hex } from "@scure/base";
import { Address, OutScript, Transaction } from "@scure/btc-signer";
import fs from "fs";
import readline from "readline";
import yargs from "yargs";
//#region src/cli.ts
const ELECTRS_CREDENTIALS = {
username: "spark-sdk",
password: "mCMk1JqlBNtetUNy"
};
function hash160(data) {
return ripemd160(sha256(data));
}
async function signPsbtWithExternalKey(psbtHex, privateKeyInput) {
const tx = Transaction.fromPSBT(hexToBytes(psbtHex), {
allowUnknown: true,
allowLegacyWitnessUtxo: true,
version: 3
});
const privateKey = hexToBytes(privateKeyInput);
for (let i = 0; i < tx.inputsLength; i++) {
const input = tx.getInput(i);
if (isEphemeralAnchorOutput(input?.witnessUtxo?.script, input?.witnessUtxo?.amount)) continue;
tx.updateInput(i, { witnessScript: input?.witnessUtxo?.script });
tx.signIdx(privateKey, i);
tx.finalizeIdx(i);
}
return bytesToHex(tx.toBytes(true, true));
}
function hexToWif(hexPrivateKey) {
try {
const privateKeyBytes = hexToBytes(hexPrivateKey);
const version = 239;
const compressionFlag = 1;
const combined = new Uint8Array([
version,
...privateKeyBytes,
compressionFlag
]);
const checksum = sha256(sha256(combined)).slice(0, 4);
const withChecksum = new Uint8Array([...combined, ...checksum]);
const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
let num = BigInt("0x" + bytesToHex(withChecksum));
let encoded = "";
while (num > 0) {
encoded = base58Alphabet[Number(num % 58n)] + encoded;
num = num / 58n;
}
for (let i = 0; i < withChecksum.length && withChecksum[i] === 0; i++) encoded = "1" + encoded;
return encoded;
} catch (error) {
throw new Error(`Failed to convert hex to WIF: ${error}`);
}
}
const commands = [
"initwallet",
"setprivacyenabled",
"getwalletsettings",
"getbalance",
"getdepositaddress",
"getstaticdepositaddress",
"getsparkaddress",
"getlatesttx",
"claimdeposit",
"claimstaticdepositquote",
"claimstaticdeposit",
"refundstaticdeposit",
"refundstaticdepositlegacy",
"refundandbroadcaststaticdeposit",
"claimstaticdepositwithmaxfee",
"instantstaticdepositquote",
"claiminstantstaticdeposit",
"getutxosfordepositaddress",
"createsparkinvoice",
"createinvoice",
"createhodlinvoice",
"payinvoice",
"createhtlc",
"claimhtlc",
"queryhtlc",
"gethtlcpreimage",
"createhtlcsenderspendtx",
"createhtlcreceiverspendtx",
"sendtransfer",
"sendtransferv2",
"withdraw",
"withdrawalfee",
"lightningsendfee",
"getlightningsendrequest",
"getlightningreceiverequest",
"getcoopexitrequest",
"gettransfers",
"transfertokens",
"gettokenl1address",
"getissuertokenbalance",
"getissuertokenmetadata",
"getissuertokenidentifier",
"getissuertokenpublickey",
"minttokens",
"burntokens",
"freezetokens",
"unfreezetokens",
"getissuertokenactivity",
"createtoken",
"nontrustydeposit",
"querytokentransactionsbytxhash",
"querytokentransactions",
"gettransferfromssp",
"gettransfer",
"encodeaddress",
"getuserrequests",
"unilateralexit",
"generatefeebumppackagetobroadcast",
"testonly_generateexternalwallet",
"signfeebump",
"checktimelock",
"getleaves",
"leafidtohex",
"testonly_generateutxostring",
"generatecpfptx",
"registerwebhook",
"deletewebhook",
"listwebhooks",
"fulfillsparkinvoice",
"querysparkinvoices",
"validateinvoicesig",
"ro:init",
"ro:balance",
"ro:tokenbalance",
"ro:transfers",
"ro:transfersbyids",
"ro:pendingtransfers",
"ro:depositaddresses",
"ro:staticdepositaddresses",
"ro:utxos",
"ro:invoices",
"ro:tokentransactions",
"enablelogging",
"setloggerlevel",
"help",
"exit",
"quit"
];
function showQueryTokenTransactionsByTxHashHelp() {
console.log("Usage: querytokentransactionsbytxhash <hash1> <hash2> ...");
console.log("");
console.log("Query token transactions by their transaction hashes.");
console.log("Primarily meant for retrieving and/or confirming the status of specific token transactions.");
console.log("");
console.log("Examples:");
console.log(" querytokentransactionsbytxhash abc123...");
console.log(" querytokentransactionsbytxhash abc123... def456... ghi789...");
}
function showQueryTokenTransactionsWithFiltersHelp() {
console.log("Usage: querytokentransactions [options]");
console.log("");
console.log("Query token transaction history with optional filters and cursor-based pagination.");
console.log("");
console.log("Options:");
console.log(" --sparkAddresses <addresses> Comma-separated list of Spark addresses (default: wallet's Spark address, use ',' for empty list)");
console.log(" --issuerPublicKeys <keys> Comma-separated list of issuer public keys (default: empty, use ',' for empty list)");
console.log(" --tokenIdentifiers <identifiers> Comma-separated list of token identifiers");
console.log(" --outputIds <ids> Comma-separated list of output IDs");
console.log(" --direction <direction> Pagination direction: 'NEXT' or 'PREVIOUS' (default: NEXT)");
console.log(" --pageSize <size> Number of results per page (default: 50, max: 100)");
console.log(" --cursor <cursor> Pagination cursor from previous response");
console.log(" --help Show this help message");
console.log("");
console.log("Examples:");
console.log(" querytokentransactions");
console.log(" querytokentransactions --sparkAddresses spark1q...");
console.log(" querytokentransactions --issuerPublicKeys 02abc123...");
console.log(" querytokentransactions --sparkAddresses addr1,addr2 --tokenIdentifiers id1,id2");
console.log(" querytokentransactions --pageSize 10 --cursor abc123...");
console.log(" querytokentransactions --pageSize 25 --cursor xyz789... --direction PREVIOUS");
console.log(" querytokentransactions --issuerPublicKeys 02abc123... --pageSize 5");
}
function parseQueryTokenTransactionsByTxHashArgs(args) {
if (args.includes("--help")) {
showQueryTokenTransactionsByTxHashHelp();
return null;
}
if (args.length === 0) {
console.log("Error: At least one transaction hash is required");
showQueryTokenTransactionsByTxHashHelp();
return null;
}
return { tokenTransactionHashes: args };
}
function parseQueryTokenTransactionsWithFiltersArgs(args) {
try {
const parsed = yargs(args).option("sparkAddresses", {
type: "string",
description: "Comma-separated list of Spark addresses",
coerce: (value) => {
if (!value) return [];
if (value === ",") return [];
return value.split(",").filter((key) => key.trim() !== "");
}
}).option("issuerPublicKeys", {
type: "string",
description: "Comma-separated list of issuer public keys",
coerce: (value) => {
if (!value) return [];
if (value === ",") return [];
return value.split(",").filter((key) => key.trim() !== "");
}
}).option("tokenIdentifiers", {
type: "string",
description: "Comma-separated list of token identifiers",
coerce: (value) => value ? value.split(",") : []
}).option("outputIds", {
type: "string",
description: "Comma-separated list of output IDs",
coerce: (value) => value ? value.split(",") : []
}).option("pageSize", {
type: "number",
description: "Limit the number of results",
default: 50
}).option("cursor", {
type: "string",
description: "Pagination cursor from previous response"
}).option("direction", {
type: "string",
description: "Pagination direction: 'NEXT' or 'PREVIOUS'",
default: "NEXT",
choices: ["NEXT", "PREVIOUS"]
}).help(false).parseSync();
if (args.includes("--help")) {
showQueryTokenTransactionsWithFiltersHelp();
return null;
}
return {
sparkAddresses: parsed.sparkAddresses,
issuerPublicKeys: parsed.issuerPublicKeys,
tokenIdentifiers: parsed.tokenIdentifiers,
outputIds: parsed.outputIds,
pageSize: parsed.pageSize,
cursor: parsed.cursor,
direction: parsed.direction
};
} catch (error) {
showQueryTokenTransactionsWithFiltersHelp();
throw error;
}
}
function displayTokenTransactions(transactions) {
console.log("\nToken Transactions:");
for (const tx of transactions) {
console.log("\nTransaction Details:");
console.log(` Status: ${TokenTransactionStatus[tx.status]}`);
let tokenIdentifier = "";
let issuerPublicKey = "";
const protoNetwork = tx.tokenTransaction?.network;
const network = protoNetwork ? protoToNetwork(protoNetwork) : void 0;
if (tx.tokenTransaction?.tokenInputs?.$case === "createInput") issuerPublicKey = hex.encode(tx.tokenTransaction?.tokenInputs.createInput.issuerPublicKey);
else {
issuerPublicKey = hex.encode(tx.tokenTransaction?.tokenOutputs[0].tokenPublicKey || new Uint8Array(0));
tokenIdentifier = bytesToHex(tx.tokenTransaction?.tokenOutputs[0]?.tokenIdentifier || new Uint8Array(0));
}
if (tokenIdentifier) {
console.log(` Raw Token Identifier: ${tokenIdentifier}`);
if (network !== void 0) {
const bech32mIdentifier = encodeBech32mTokenIdentifier({
tokenIdentifier: hexToBytes(tokenIdentifier),
network: Network[network]
});
console.log(` Token Identifier: ${bech32mIdentifier}`);
}
} else console.log(` Issuer Public Key: ${issuerPublicKey}`);
if (tx.tokenTransaction?.tokenInputs) {
const input = tx.tokenTransaction.tokenInputs;
if (input.$case === "mintInput") {
console.log(" Type: Mint");
console.log(` Issuer Public Key: ${hex.encode(input.mintInput.issuerPublicKey)}`);
console.log(` Timestamp: ${tx.tokenTransaction.clientCreatedTimestamp?.toISOString() || "N/A"}`);
} else if (input.$case === "transferInput") {
console.log(" Type: Transfer");
console.log(` Outputs to Spend: ${input.transferInput.outputsToSpend.length}`);
} else if (input.$case === "createInput") {
console.log(" Type: Create");
console.log(` Token Name: ${input.createInput.tokenName}`, ` Token Ticker: ${input.createInput.tokenTicker}`, ` Max Supply: ${hex.encode(input.createInput.maxSupply)} (decimal: ${bytesToNumberBE(input.createInput.maxSupply)})`, ` Decimals: ${input.createInput.decimals}`, ` Is Freezable: ${input.createInput.isFreezable}`, ` Creation Entity Public Key: ${hex.encode(input.createInput.creationEntityPublicKey)}`);
}
}
if (tx.tokenTransaction?.tokenOutputs) {
console.log("\n Outputs:");
for (const output of tx.tokenTransaction.tokenOutputs) {
console.log(` Output ID: ${output.id}`);
console.log(` Owner Public Key: ${hex.encode(output.ownerPublicKey)}`);
console.log(output.ownerPublicKey && network !== void 0 ? ` Owner Spark Address: ${encodeSparkAddress({
identityPublicKey: bytesToHex(output.ownerPublicKey),
network: Network[network]
})}` : "");
console.log(` Token Amount: 0x${hex.encode(output.tokenAmount)} (decimal: ${bytesToNumberBE(output.tokenAmount)})`);
if (output.withdrawBondSats !== void 0) console.log(` Withdraw Bond Sats: ${output.withdrawBondSats}`);
if (output.withdrawRelativeBlockLocktime !== void 0) console.log(` Withdraw Relative Block Locktime: ${output.withdrawRelativeBlockLocktime}`);
console.log(" ---");
}
}
if (tx.tokenTransaction?.invoiceAttachments) {
console.log(" Invoice Attachments:");
for (const attachment of tx.tokenTransaction.invoiceAttachments) console.log(` Invoice: ${attachment.sparkInvoice}`);
}
console.log("----------------------------------------");
}
}
function parseCreateSparkInvoiceArgsWithYargs(args) {
try {
const underscore = (v) => v === "_" ? void 0 : v;
const parsed = yargs(args).command("$0 <asset> [amount] [memo] [senderSparkAddress] [expiryTime]", false, (y) => y.positional("asset", {
describe: "btc or tokenIdentifier",
type: "string"
}).positional("amount", {
describe: "Amount to send (optional)",
type: "string"
}).positional("memo", {
describe: "Optional memo, use _ for empty",
type: "string"
}).positional("senderSparkAddress", {
describe: "Optional sender spark address, use _ for empty",
type: "string"
}).positional("expiryTime", {
describe: "seconds from now, use _ for empty",
type: "string"
})).help().version(false).exitProcess(false).parseSync();
return {
asset: underscore(parsed.asset),
amount: underscore(parsed.amount),
memo: underscore(parsed.memo),
senderSparkAddress: underscore(parsed.senderSparkAddress),
expiryTime: underscore(parsed.expiryTime)
};
} catch (err) {
console.error("Error: createsparkinvoice <asset> [amount] [memo] [senderPublicKey] [expiryTime]", err);
throw err;
}
}
const CLI_VERSION = "0.0.120";
function parseCliArgs() {
const argv = process.argv.slice(2);
if (argv.includes("--version") || argv.includes("-v")) {
console.log(`@buildonspark/cli v${CLI_VERSION}`);
process.exit(0);
}
if (argv.includes("--help") || argv.includes("-h")) {
console.log(`Usage: spark-cli [options]
Options:
--network <network> Network to connect to (mainnet, regtest, local) [default: regtest]
--config <path> Path to a JSON config file
--exec <command> Execute a command non-interactively and exit (can be repeated)
--mnemonic <words> 12-word BIP39 mnemonic (auto-initializes wallet; also settable in config JSON)
--seed <hex> Hex seed (auto-initializes wallet; also settable in config JSON)
-v, --version Print version
-h, --help Show this help message
Environment variables:
NETWORK Network override (same values as --network)
CONFIG_FILE Config file path override
NODE_ENV Set to "development" for dev mode`);
process.exit(0);
}
let networkArg;
const networkIdx = argv.indexOf("--network");
if (networkIdx !== -1 && networkIdx + 1 < argv.length) networkArg = argv[networkIdx + 1];
let configArg;
const configIdx = argv.indexOf("--config");
if (configIdx !== -1 && configIdx + 1 < argv.length) configArg = argv[configIdx + 1];
const rawNetwork = (networkArg ?? process.env.NETWORK ?? "regtest").toUpperCase();
let network;
if (rawNetwork === "MAINNET") network = "MAINNET";
else if (rawNetwork === "LOCAL") network = "LOCAL";
else network = "REGTEST";
const execCommands = [];
for (let i = 0; i < argv.length; i++) if (argv[i] === "--exec" && i + 1 < argv.length) {
execCommands.push(argv[i + 1]);
i++;
}
let mnemonicArg;
const mnemonicIdx = argv.indexOf("--mnemonic");
if (mnemonicIdx !== -1 && mnemonicIdx + 1 < argv.length) mnemonicArg = argv[mnemonicIdx + 1];
let seedArg;
const seedIdx = argv.indexOf("--seed");
if (seedIdx !== -1 && seedIdx + 1 < argv.length) seedArg = argv[seedIdx + 1];
const configFile = configArg ?? process.env.CONFIG_FILE;
return {
network,
configFile,
mnemonic: mnemonicArg,
seed: seedArg,
execCommands
};
}
async function runCLI() {
const { network, configFile, mnemonic: cliMnemonic, seed: cliSeed, execCommands } = parseCliArgs();
let config = {};
if (configFile) try {
const data = fs.readFileSync(configFile, "utf8");
config = JSON.parse(data);
if (config.network !== network) {
console.error("Network mismatch in config file");
return;
}
} catch (err) {
console.error("Error reading config file:", err);
return;
}
else switch (network) {
case "MAINNET":
config = WalletConfig.MAINNET;
break;
case "REGTEST":
config = WalletConfig.REGTEST;
break;
default:
config = WalletConfig.LOCAL;
break;
}
let wallet;
let coopExitFeeQuote;
let readonlyClient;
const isExecMode = execCommands.length > 0;
if (cliMnemonic && cliSeed) {
console.error("Error: --mnemonic and --seed are mutually exclusive");
process.exit(1);
}
const configData = config;
const autoMnemonicOrSeed = cliMnemonic ?? cliSeed ?? configData["mnemonic"] ?? configData["seed"];
if (typeof autoMnemonicOrSeed === "string" && autoMnemonicOrSeed.length > 0) try {
const { wallet: newWallet } = await IssuerSparkWallet.initialize({
mnemonicOrSeed: autoMnemonicOrSeed,
options: {
...config,
network
}
});
wallet = newWallet;
console.log("Auto-initialized wallet");
console.log("Network:", network);
} catch (err) {
console.error("Failed to auto-initialize wallet:", err);
if (isExecMode) process.exit(1);
}
else if (isExecMode) console.warn("Warning: no --mnemonic or --seed provided; commands requiring a wallet will fail");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: (line) => {
const completions = commands.filter((c) => c.startsWith(line));
return [completions.length ? completions : commands, line];
}
});
const helpMessage = `
Available commands:
initwallet [mnemonic | seed] - Create a new wallet from a mnemonic or seed. If no mnemonic or seed is provided, a new mnemonic will be generated.
setprivacyenabled <true|false> - Set the privacy enabled setting for the wallet
getwalletsettings - Get the wallet's settings
getbalance - Get the wallet's balance (available, pending, incoming)
getdepositaddress - Get an address to deposit funds from L1 to Spark
getstaticdepositaddress - Get a static address to deposit funds from L1 to Spark
identity - Get the wallet's identity public key
getsparkaddress - Get the wallet's spark address
encodeaddress <identityPublicKey> <network> (mainnet, regtest, testnet, signet, local) - Encodes a identity public key to a spark address
decodesparkaddress <sparkAddress> <network(MAINNET|REGTEST|SIGNET|TESTNET|LOCAL))> - Decode a spark address to get the identity public key
getlatesttx <address> - Get the latest deposit transaction id for an address
claimdeposit <txid> - Claim any pending deposits to the wallet
claimstaticdepositquote <txid> [outputIndex] - Get a quote for claiming a static deposit
claimstaticdeposit <txid> <creditAmountSats> <sspSignature> [outputIndex] - Claim a static deposits
claimstaticdepositwithmaxfee <txid> <maxFee> [outputIndex] - Claim a static deposit with a max fee
instantstaticdepositquote <txid> [outputIndex] [partnerId] - Get an instant static deposit quote
claiminstantstaticdeposit <quoteJson> [planIndex] [txid] [outputIndex] - Claim an instant static deposit (paste JSON from instantstaticdepositquote, override txid/vout for RBF)
getutxosfordepositaddress <depositAddress> <excludeClaimed(true|false)> - Get all UTXOs for a deposit address
refundstaticdepositlegacy <depositTransactionId> <destinationAddress> <fee> [outputIndex] - Refund a static deposit legacy
refundstaticdeposit <depositTransactionId> <destinationAddress> <satsPerVbyteFee> [outputIndex] - Refund a static deposit
refundandbroadcaststaticdeposit <depositTransactionId> <destinationAddress> <satsPerVbyteFee> [outputIndex] - Refund and broadcast a static deposit
gettransfers [limit] [offset] - Get a list of transfers
createinvoice <amount> <memo> <includeSparkAddress> <includeSparkInvoice> [receiverIdentityPubkey] [descriptionHash] - Create a new lightning invoice (includeSparkAddress and includeSparkInvoice are mutually exclusive)
createhodlinvoice <amount> <paymentHash> <memo> <includeSparkAddress> <includeSparkInvoice> [receiverIdentityPubkey] [descriptionHash] - Create a HODL lightning invoice with payment hash (includeSparkAddress and includeSparkInvoice are mutually exclusive)
payinvoice <invoice> <maxFeeSats> <preferSpark> [amountSatsToSend] - Pay a lightning invoice
createsparkinvoice <asset("btc" | tokenIdentifier)> [amount] [memo] [senderPublicKey] [expiryTime] - Create a spark payment request. Amount is optional. Use _ for empty optional fields eg createsparkinvoice btc _ memo _ _
createhtlc <receiverSparkAddress> <amountSats> <expiryTimeMinutes> <preimage> - Create a HTLC
claimhtlc <preimage> - Claim a HTLC
queryhtlc <paymentHashes> <status> <transferIds> <matchRole> - Query a HTLC
getHTLCPreimage <transferID> - Get the preimage for a HTLC
createhtlcsenderspendtx <htlcTx> <sequence> <hash> <hashLockDestinationPubkey> <sequenceLockDestinationPubkey> <satsPerVbyteFee> - Create a sender spend transaction for a HTLC
createhtlcreceiverspendtx <htlcTx> <hash> <hashLockDestinationPubkey> <sequenceLockDestinationPubkey> <preimage> <satsPerVbyteFee> - Create a receiver spend transaction for a HTLC
sendtransfer <amount> <receiverSparkAddress> - Send a spark transfer
sendtransferv2 <address1:amount1> [address2:amount2] ... - Send sats to one or more Spark addresses in a single atomic transfer
withdraw <amount> <onchainAddress> <exitSpeed(FAST|MEDIUM|SLOW)> [deductFeeFromWithdrawalAmount(true|false)] - Withdraw funds to an L1 address
withdrawalfee <amount> <withdrawalAddress> - Get a fee estimate for a withdrawal (cooperative exit)
lightningsendfee <invoice> - Get a fee estimate for a lightning send
getlightningsendrequest <requestId> - Get a lightning send request by ID
getlightningreceiverequest <requestId> - Get a lightning receive request by ID
getcoopexitrequest <requestId> - Get a coop exit request by ID
unilateralexit [testmode=true] - Interactive unilateral exit flow (normal mode: timelocks must be naturally expired, test mode: automatically expires timelocks)
generatefeebumppackagetobroadcast <feeRate> <utxo1:txid:vout:value:script:publicKey> [utxo2:...] [nodeHexString1] [nodeHexString2 ...] - Get fee bump packages for unilateral exit transactions (if no nodes provided, uses all wallet leaves)
signfeebump <feeBumpPsbt> <privateKey> - Sign a fee bump package with the utxo private key
testonly_generateexternalwallet - Generate test wallet to fund utxos for fee bumping
testonly_generateutxostring <txid> <vout> <value> <publicKey> - Generate correctly formatted UTXO string from your public key
checktimelock <leafId> - Get the remaining timelock for a given leaf
generatefeebumptx <cpfpTx> - Generate a fee bump transaction for a given cpfp transaction
leafidtohex <leafId1> [leafId2] [leafId3] ... - Convert leaf ID to hex string for unilateral exit
getleaves - Get all leaves owned by the wallet
fulfillsparkinvoice <invoice1[:amount1]> <invoice2[:amount2]> ... - Fulfill one or more Spark token invoices (append :amount if invoice has no preset amount)
querysparkinvoices <invoice1> <invoice2> ... - Query Spark token invoices raw invoice strings
getuserrequests [--first <number>] [--after <cursor>] [--types <types>] [--statuses <statuses>] [--networks <networks>] - Get user requests for the wallet
💡 Simplified Unilateral Exit Flow:
'unilateralexit' for interactive exit flow (normal mode - timelocks must be naturally expired).
'unilateralexit testmode=true' for interactive exit flow with automatic timelock expiration.
'generatefeebumppackagetobroadcast <feeRate> <utxos>' for fee bumping.
The advanced commands below are for specific use cases.
Token Holder Commands:
transfertokens <tokenIdentifier> <receiverAddress> <amount> - Transfer tokens. If the token was created with 2 decimals, transfertokens _ _ 1 would transfer 0.01 tokens.
batchtransfertokens <tokenIdentifier1:receiverAddress1:amount1> <tokenIdentifier2:receiverAddress2:amount2> ... - Transfer tokens with multiple outputs (supports different token types)
querytokentransactionsbytxhash <hash1> <hash2> ... - Query token transactions by transaction hashes
querytokentransactions [--sparkAddresses] [--issuerPublicKeys] [--tokenIdentifiers] [--outputIds] [--pageSize] [--cursor] [--direction] - Query token transaction history with filters
Token Issuer Commands:
gettokenl1address - Get the L1 address for on-chain token operations
getissuertokenbalance - Get the issuer's token balance
getissuertokenmetadata - Get the issuer's token metadata
getissuertokenidentifier - Get the issuer's token identifier
getissuertokenpublickey - Get the issuer's token public key
minttokens <amount> <tokenIdentifier> - Mint new tokens. If the token was created with 2 decimals, minttokens 1 would transfer 0.01 tokens.
burntokens <amount> <tokenIdentifier> - Burn tokens. If the token was created with 2 decimals, burntokens 1 would burn 0.01 tokens.
freezetokens <sparkAddress> <tokenIdentifier> - Freeze tokens for a specific address
unfreezetokens <sparkAddress> <tokenIdentifier> - Unfreeze tokens for a specific address
createtoken <tokenName> <tokenTicker> <decimals> <maxSupply> <isFreezable> <extraMetadata> - Create a new token. Use "_", or leave blank, to denote empty extra metadata.
decodetokenidentifier <tokenIdentifier> - Returns the raw token identifier as a hex string
Readonly Client Commands (read-only queries against any spark address, no wallet required):
ro:init public - Initialize a public (unauthenticated) readonly client
ro:init master <mnemonic|seed> [accountNumber] - Initialize an authenticated readonly client with a master key
ro:balance <sparkAddress> - Get available sats balance for a spark address
ro:tokenbalance <sparkAddress> [tokenIdentifier1,tokenIdentifier2] - Get token balances for a spark address
ro:transfers <sparkAddress> [limit] [offset] - Query transfers for a spark address
ro:transfersbyids <id1> [id2] ... - Look up specific transfers by their IDs
ro:pendingtransfers <sparkAddress> - Query pending inbound transfers
ro:depositaddresses <sparkAddress> [limit] [offset] - Query unused deposit addresses
ro:staticdepositaddresses <sparkAddress> - Query static deposit addresses
ro:utxos <depositAddress> [limit] [offset] [excludeClaimed] - Get UTXOs for a deposit address
ro:invoices <invoice1> [invoice2] ... [--limit N] [--offset N] - Query spark invoice statuses
ro:tokentransactions [--sparkAddresses] [--issuerPublicKeys] [--tokenIdentifiers] [--pageSize] [--cursor] [--direction] - Query token transactions
enablelogging <true|false> - Enable or disable logging
setloggerlevel <trace|info> - Set the logging level
help - Show this help message
exit/quit - Exit the program
`;
if (!isExecMode) {
console.log(helpMessage);
console.log("\x1B[41m%s\x1B[0m", "⚠️ WARNING: This is an example CLI implementation and is not intended for production use. Use at your own risk. The official package is available at https://www.npmjs.com/package/@buildonspark/spark-sdk ⚠️");
}
let execIndex = 0;
while (true) {
let command;
if (isExecMode) {
if (execIndex >= execCommands.length) {
rl.close();
if (wallet) await wallet.cleanupConnections();
break;
}
command = execCommands[execIndex++];
console.log(`---exec:${command.split(" ")[0]}---`);
} else command = await new Promise((resolve) => {
rl.question("> ", resolve);
});
const [firstWord, ...args] = command.split(" ");
const lowerCommand = firstWord.toLowerCase();
if (lowerCommand === "exit" || lowerCommand === "quit") {
rl.close();
break;
}
try {
switch (lowerCommand) {
case "help":
console.log(helpMessage);
break;
case "enablelogging":
SparkSdkLogger.setAllEnabled(args[0] === "true");
break;
case "setloggerlevel":
SparkSdkLogger.setAllLevels(args[0] === "trace" ? LoggingLevel.Trace : LoggingLevel.Info);
break;
case "setprivacyenabled":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
await wallet.setPrivacyEnabled(args[0] === "true");
break;
case "getwalletsettings":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const walletSettings = await wallet.getWalletSettings();
console.log(walletSettings);
break;
case "registerwebhook": {
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args.length < 2) {
console.log("Usage: registerwebhook <secret> <url> [event_types]");
console.log(" event_types: comma-separated list of event types (optional, defaults to all)");
console.log(" Available types: " + Object.values(SparkWalletWebhookEventType).join(", "));
break;
}
const [webhookSecret, webhookUrl, eventTypesArg] = args;
const eventTypes = eventTypesArg ? eventTypesArg.split(",").map((t) => t.trim()) : Object.values(SparkWalletWebhookEventType);
const result = await wallet.registerSparkWalletWebhook({
secret: webhookSecret,
url: webhookUrl,
event_types: eventTypes
});
console.log("Webhook registered:", result);
break;
}
case "deletewebhook": {
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args.length < 1) {
console.log("Usage: deletewebhook <webhook_id>");
break;
}
const [webhookId] = args;
const deleteResult = await wallet.deleteSparkWalletWebhook({ webhook_id: webhookId });
console.log("Webhook deleted:", deleteResult);
break;
}
case "listwebhooks": {
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const listResult = await wallet.listSparkWalletWebhooks();
console.log("Webhooks:", JSON.stringify(listResult, null, 2));
break;
}
case "nontrustydeposit":
if (process.env.NODE_ENV !== "development" || network !== "REGTEST") {
console.log("This command is only available in the development environment and on the REGTEST network");
break;
}
/**
* This is an example of how to create a non-trusty deposit. Real implementation may differ.
*
* 1. Get an address to deposit funds from L1 to Spark
* 2. Construct a tx spending from the L1 address to the Spark address
* 3. Call initalizeDeposit with the tx hex
* 4. Sign the tx
* 5. Broadcast the tx
*/
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args.length !== 1) {
console.log("Usage: nontrustydeposit <destinationBtcAddress>");
break;
}
const privateKey = "9303c68c414a6208dbc0329181dd640b135e669647ad7dcb2f09870c54b26ed9";
const sourceAddress = "bcrt1pzrfhq4gm7kuww875lkj27cx005x08g2jp6qxexnu68gytn7sjqss3s6j2c";
try {
const headers = {};
if (network === "REGTEST") headers["Authorization"] = `Basic ${btoa(`${ELECTRS_CREDENTIALS.username}:${ELECTRS_CREDENTIALS.password}`)}`;
const transactions = await (await fetch(`${config.electrsUrl}/address/${sourceAddress}/txs`, { headers })).json();
const utxos = [];
for (const tx of transactions) for (let voutIndex = 0; voutIndex < tx.vout.length; voutIndex++) {
const output = tx.vout[voutIndex];
if (output.scriptpubkey_address === sourceAddress) {
if (!transactions.some((otherTx) => otherTx.vin.some((input) => input.txid === tx.txid && input.vout === voutIndex))) utxos.push({
txid: tx.txid,
vout: voutIndex,
value: BigInt(output.value),
scriptPubKey: output.scriptpubkey,
desc: output.desc
});
}
}
if (utxos.length === 0) {
console.log(`No unspent outputs found. Please fund the address ${sourceAddress} first`);
break;
}
const tx = new Transaction();
const sendAmount = 10000n;
const utxo = utxos[0];
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: getP2TRScriptFromPublicKey(secp256k1.getPublicKey(hexToBytes(privateKey)), Network.REGTEST),
amount: utxo.value
},
tapInternalKey: schnorr.getPublicKey(hexToBytes(privateKey))
});
const destinationAddress = Address(getNetwork(Network.REGTEST)).decode(args[0]);
const desitnationScript = OutScript.encode(destinationAddress);
tx.addOutput({
script: desitnationScript,
amount: sendAmount
});
console.log("Initializing deposit with unsigned transaction...");
const depositResult = await wallet.advancedDeposit(tx.hex);
console.log("Deposit initialization result:", depositResult);
console.log("Signing transaction...");
tx.sign(hexToBytes(privateKey));
tx.finalize();
const signedTxHex = hex.encode(tx.extract());
const broadcastResponse = await fetch(`${config.electrsUrl}/tx`, {
method: "POST",
headers: {
Authorization: "Basic " + Buffer.from("spark-sdk:mCMk1JqlBNtetUNy").toString("base64"),
"Content-Type": "text/plain"
},
body: signedTxHex
});
if (!broadcastResponse.ok) {
const error = await broadcastResponse.text();
throw new Error(`Failed to broadcast transaction: ${error}`);
}
const txid = await broadcastResponse.text();
console.log("Transaction broadcast successful!", txid);
} catch (error) {
console.error("Error creating deposit:", error);
console.error("Error details:", error.message);
}
break;
case "getlatesttx":
const latestTx = await getLatestDepositTxId(args[0]);
console.log(latestTx);
break;
case "gettransferfromssp":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const transfer1 = await wallet.getTransferFromSsp(args[0]);
console.log(transfer1);
break;
case "gettransfer":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const transfer2 = await wallet.getTransfer(args[0]);
console.log(transfer2);
break;
case "claimdeposit":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const depositResult = await wallet.claimDeposit(args[0]);
await new Promise((resolve) => setTimeout(resolve, 1e3));
console.log(depositResult);
break;
case "gettransfers":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const limit = args[0] ? parseInt(args[0]) : 10;
const offset = args[1] ? parseInt(args[1]) : 0;
if (isNaN(limit) || isNaN(offset)) {
console.log("Invalid limit or offset");
break;
}
if (limit < 0 || offset < 0) {
console.log("Limit and offset must be non-negative");
break;
}
const transfers = await wallet.getTransfers(limit, offset);
console.log(transfers);
break;
case "getlightningsendrequest":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const lightningSendRequest = await wallet.getLightningSendRequest(args[0]);
console.log(lightningSendRequest);
break;
case "getlightningreceiverequest":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const lightningReceiveRequest = await wallet.getLightningReceiveRequest(args[0]);
console.log(lightningReceiveRequest);
break;
case "getcoopexitrequest":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const coopExitRequest = await wallet.getCoopExitRequest(args[0]);
console.log(coopExitRequest);
break;
case "initwallet":
if (wallet) await wallet.cleanupConnections();
let mnemonicOrSeed;
let accountNumber;
if (args.length == 13) {
mnemonicOrSeed = args.slice(0, -1).join(" ");
accountNumber = parseInt(args[args.length - 1]);
} else if (args.length == 12) mnemonicOrSeed = args.join(" ");
else if (args.length == 2) {
mnemonicOrSeed = args[0];
accountNumber = parseInt(args[1]);
} else if (args.length == 1) mnemonicOrSeed = args[0];
else if (args.length !== 0) {
console.log("Invalid number of arguments - usage: initwallet [mnemonic | seed] [accountNumber (optional)]");
break;
}
let options = {
...config,
network
};
try {
const { wallet: newWallet, mnemonic: newMnemonic } = await IssuerSparkWallet.initialize({
mnemonicOrSeed,
options,
accountNumber
});
wallet = newWallet;
console.log("Mnemonic:", newMnemonic);
console.log("Network:", options.network);
wallet.on(SparkWalletEvent.DepositConfirmed, (depositId, balance) => {
console.log(`Deposit ${depositId} marked as available. New balance: ${balance}`);
});
wallet.on(SparkWalletEvent.TransferClaimed, (transferId, balance) => {
console.log(`Transfer ${transferId} claimed. New balance: ${balance}`);
});
wallet.on(SparkWalletEvent.TokenBalanceUpdate, (event) => {
console.log("Token balance update:");
for (const tx of event.finalizedTokenTransactions) {
const hashHex = Buffer.from(tx.tokenTransactionHash).toString("hex");
console.log(` tx: ${hashHex}`);
if (tx.tokenIdentifiers.length > 0) console.log(` tokens: ${tx.tokenIdentifiers.join(", ")}`);
if (tx.sparkInvoices.length > 0) console.log(` invoices: ${tx.sparkInvoices.join(", ")}`);
}
for (const [id, info] of event.tokenBalances.entries()) console.log(` ${id}: ${info.ownedBalance}`);
});
wallet.on(SparkWalletEvent.StreamConnected, () => {
console.log("Stream connected");
});
wallet.on(SparkWalletEvent.StreamReconnecting, (attempt, maxAttempts, delayMs, error) => {
console.log("Stream reconnecting", attempt, maxAttempts, delayMs, error);
});
wallet.on(SparkWalletEvent.StreamDisconnected, (reason) => {
console.log("Stream disconnected", reason);
});
} catch (error) {
console.error("Error initializing wallet:", error);
break;
}
break;
case "getbalance":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const balanceInfo = await wallet.getBalance();
const { satsBalance } = balanceInfo;
console.log("Sats Balance:");
console.log(" Available: " + satsBalance.available);
console.log(" Owned: " + satsBalance.owned);
if (satsBalance.incoming > 0n) console.log(" Incoming: " + satsBalance.incoming);
if (balanceInfo.tokenBalances && balanceInfo.tokenBalances.size > 0) {
console.log("\nToken Balances: [<tokenIdentifier> (<issuerPublicKey>)]");
for (const [bech32mTokenIdentifier, tokenInfo] of balanceInfo.tokenBalances.entries()) {
console.log(` ${bech32mTokenIdentifier} (${tokenInfo.tokenMetadata.tokenPublicKey}):`);
console.log(` Owned balance: ${tokenInfo.ownedBalance}`);
if (tokenInfo.availableToSendBalance < tokenInfo.ownedBalance) console.log(` Available to send: ${tokenInfo.availableToSendBalance}`);
}
}
break;
case "getdepositaddress":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const depositAddress = await wallet.getSingleUseDepositAddress();
console.log("WARNING: This is a single-use address, DO NOT deposit more than once or you will lose funds!");
console.log(depositAddress);
break;
case "getstaticdepositaddress":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const staticDepositAddress = await wallet.getStaticDepositAddress();
console.log("This is a multi-use address.");
console.log(staticDepositAddress);
break;
case "claimstaticdepositquote":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args[1] === void 0) {
const claimDepositQuote = await wallet.getClaimStaticDepositQuote(args[0]);
console.log(claimDepositQuote);
} else {
const outputIndex = parseInt(args[1]);
const claimDepositQuote = await wallet.getClaimStaticDepositQuote(args[0], outputIndex);
console.log(claimDepositQuote);
}
break;
case "claimstaticdeposit":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args[3] === void 0) {
const claimDeposit = await wallet.claimStaticDeposit({
transactionId: args[0],
creditAmountSats: parseInt(args[1]),
sspSignature: args[2]
});
console.log(claimDeposit);
} else {
const claimDeposit = await wallet.claimStaticDeposit({
transactionId: args[0],
creditAmountSats: parseInt(args[1]),
sspSignature: args[2],
outputIndex: parseInt(args[3])
});
console.log(claimDeposit);
}
break;
case "claimstaticdepositwithmaxfee":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args[2] === void 0) {
const claimDepositWithMaxFee = await wallet.claimStaticDepositWithMaxFee({
transactionId: args[0],
maxFee: parseInt(args[1])
});
console.log(claimDepositWithMaxFee);
} else {
const claimDepositWithMaxFee = await wallet.claimStaticDepositWithMaxFee({
transactionId: args[0],
maxFee: parseInt(args[1]),
outputIndex: parseInt(args[2])
});
console.log(claimDepositWithMaxFee);
}
break;
case "instantstaticdepositquote":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
{
const txid = args[0];
const outputIdx = args[1] !== void 0 ? parseInt(args[1]) : void 0;
const partnerId = args[2];
const instantQuote = await wallet.experimental_GetInstantStaticDepositQuote(txid, outputIdx, partnerId);
console.log(JSON.stringify(instantQuote, null, 2));
}
break;
case "claiminstantstaticdeposit":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
{
const quoteData = JSON.parse(args[0]);
const planIdx = args[1] !== void 0 ? parseInt(args[1]) : 0;
if (planIdx < 0 || planIdx >= quoteData.fulfillmentPlans.length) {
console.log(`Error: planIndex ${planIdx} out of range (0-${quoteData.fulfillmentPlans.length - 1})`);
break;
}
const txid = args[2] || quoteData.quote.transactionId;
const outputIdx = args[3] !== void 0 ? parseInt(args[3]) : quoteData.quote.outputIndex;
const result = await wallet.experimental_ClaimInstantStaticDeposit({
quote: quoteData.quote,
plan: quoteData.fulfillmentPlans[planIdx],
transactionId: txid,
outputIndex: outputIdx
});
console.log(result);
}
break;
case "getutxosfordepositaddress":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const utxos = await wallet.getUtxosForDepositAddress(args[0], 10, 0, args[1] === "true");
console.log(utxos);
break;
case "refundstaticdepositlegacy":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const refundDepositLegacy = await wallet.refundStaticDeposit({
depositTransactionId: args[0],
destinationAddress: args[1],
fee: parseInt(args[2]),
outputIndex: args[3] ? parseInt(args[3]) : void 0
});
console.log("Broadcast the transaction below to refund the deposit");
console.log(refundDepositLegacy);
break;
case "refundstaticdeposit":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const refundDeposit = await wallet.refundStaticDeposit({
depositTransactionId: args[0],
destinationAddress: args[1],
satsPerVbyteFee: parseInt(args[2]),
outputIndex: args[3] ? parseInt(args[3]) : void 0
});
console.log("Broadcast the transaction below to refund the deposit");
console.log(refundDeposit);
break;
case "refundandbroadcaststaticdeposit":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const refundedTxId = await wallet.refundAndBroadcastStaticDeposit({
depositTransactionId: args[0],
destinationAddress: args[1],
satsPerVbyteFee: parseInt(args[2]),
outputIndex: args[3] ? parseInt(args[3]) : void 0
});
console.log("Refund transaction broadcasted! Transaction ID:");
console.log(refundedTxId);
break;
case "identity":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const identity = await wallet.getIdentityPublicKey();
console.log(identity);
break;
case "getsparkaddress":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
const sparkAddress = await wallet.getSparkAddress();
console.log(sparkAddress);
break;
case "decodesparkaddress":
if (args.length !== 2) {
console.log("Usage: decodesparkaddress <sparkAddress> <network> (mainnet, regtest, testnet, signet, local)");
break;
}
const decodedAddress = decodeSparkAddress(args[0], args[1].toUpperCase());
console.log(decodedAddress);
break;
case "encodeaddress":
if (args.length !== 2) {
console.log("Usage: encodeaddress <identityPublicKey> <network> (mainnet, regtest, testnet, signet, local)");
break;
}
const encodedAddress = encodeSparkAddress({
identityPublicKey: args[0],
network: args[1].toUpperCase()
});
console.log(encodedAddress);
break;
case "createinvoice":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args[2] === "true" && args[3] === "true") {
console.log("Error: includeSparkAddress and includeSparkInvoice are mutually exclusive");
break;
}
const invoice = await wallet.createLightningInvoice({
amountSats: parseInt(args[0]),
memo: args[1],
includeSparkAddress: args[2] === "true",
includeSparkInvoice: args[3] === "true",
receiverIdentityPubkey: args[4],
descriptionHash: args[5]
});
console.log(invoice);
break;
case "createhodlinvoice":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
if (args[3] === "true" && args[4] === "true") {
console.log("Error: includeSparkAddress and includeSparkInvoice are mutually exclusive");
break;
}
const hodlInvoice = await wallet.createLightningHodlInvoice({
amountSats: parseInt(args[0]),
paymentHash: args[1],
memo: args[2],
includeSparkAddress: args[3] === "true",
includeSparkInvoice: args[4] === "true",
receiverIdentityPubkey: args[5],
descriptionHash: args[6]
});
console.log(hodlInvoice);
break;
case "payinvoice":
if (!wallet) {
console.log("Please initialize a wallet first");
break;
}
let maxFeeSats = parseInt(args[1]);
if (isNaN(maxFeeSats)) {
console.log("Invalid maxFeeSats value");
break;
}
const payment = await wallet.payLightningInvoice({
invoice: args[0],
max