UNPKG

@buildonspark/cli

Version:

Spark CLI

1,143 lines (1,135 loc) 111 kB
#!/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