UNPKG

@stacks/cli

Version:
1,565 lines (1,385 loc) • 67.8 kB
import * as scureBip39 from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { buildPreorderNameTx, buildRegisterNameTx } from '@stacks/bns'; import { bytesToHex } from '@stacks/common'; import { ACCOUNT_PATH, broadcastTransaction, Cl, ClarityAbi, ClarityValue, ContractCallPayload, createContractCallPayload, cvToJSON, cvToString, estimateTransactionByteLength, fetchAbi, fetchCallReadOnlyFunction, fetchFeeEstimateTransaction, fetchFeeEstimateTransfer, getAddressFromPrivateKey, makeContractCall, makeContractDeploy, makeSTXTokenTransfer, PostConditionMode, privateKeyToPublic, ReadOnlyFunctionOptions, serializePayload, SignedContractCallOptions, SignedContractDeployOptions, SignedTokenTransferOptions, signWithKey, StacksTransactionWire, TransactionSigner, TxBroadcastResult, validateContractCall, } from '@stacks/transactions'; import * as bitcoin from 'bitcoinjs-lib'; import * as blockstack from 'blockstack'; import * as crypto from 'crypto'; import * as fs from 'fs'; import { prompt } from 'inquirer'; import fetch from 'node-fetch'; import * as path from 'path'; import * as process from 'process'; import * as winston from 'winston'; // eslint-disable-next-line @typescript-eslint/no-var-requires const c32check = require('c32check'); import { UserData } from '@stacks/auth'; import 'cross-fetch/polyfill'; import { StackerInfo, StackingClient } from '@stacks/stacking'; import { AccountsApi, Configuration, FaucetsApi } from '@stacks/blockchain-api-client'; import { GaiaHubConfig } from '@stacks/storage'; import { extractAppKey, getApplicationKeyInfo, getOwnerKeyInfo, getPaymentKeyInfo, getStacksWalletKeyInfo, OwnerKeyInfoType, PaymentKeyInfoType, StacksKeyInfoType, STX_WALLET_COMPATIBLE_SEED_STRENGTH, } from './keys'; import { checkArgs, CLI_ARGS, CLIOptAsBool, CLIOptAsString, CLIOptAsStringArray, DEFAULT_CONFIG_PATH, DEFAULT_CONFIG_TESTNET_PATH, getCLIOpts, ID_ADDRESS_PATTERN, loadConfig, makeAllCommandsList, makeCommandUsageString, STACKS_ADDRESS_PATTERN, USAGE, } from './argparse'; import { decryptBackupPhrase, encryptBackupPhrase } from './encrypt'; import { CLI_NETWORK_OPTS, CLINetworkAdapter, getNetwork, getStacksNetwork, NameInfoType, } from './network'; import { gaiaAuth, gaiaConnect, gaiaUploadProfileAll, getGaiaAddressFromProfile } from './data'; import { defaultUrlFromNetwork, STACKS_TESTNET } from '@stacks/network'; import { internal_parseCommaSeparated } from '@stacks/transactions'; import { generateNewAccount, generateWallet, getAppPrivateKey, restoreWalletAccounts, } from '@stacks/wallet-sdk'; import { getMaxIDSearchIndex, getPrivateKeyAddress, setMaxIDSearchIndex } from './common'; import { canonicalPrivateKey, ClarityFunctionArg, decodePrivateKey, generateExplorerTxPageUrl, getBackupPhrase, getIDAppKeys, getNameInfoEasy, getpass, IDAppKeys, isTestnetAddress, JSONStringify, makeProfileJWT, makePromptsFromArgList, mkdirs, parseClarityFunctionArgAnswers, SubdomainOp, subdomainOpToZFPieces, } from './utils'; // global CLI options let txOnly = false; let estimateOnly = false; let safetyChecks = true; let receiveFeesPeriod = 52595; let gracePeriod = 5000; const noExit = false; let BLOCKSTACK_TEST = !!process.env.BLOCKSTACK_TEST; /* * Sign a profile. * @path (string) path to the profile * @privateKey (string) the owner key (must be single-sig) */ // TODO: fix, network is never used // @ts-ignore function profileSign(_network: CLINetworkAdapter, args: string[]): Promise<string> { const profilePath = args[0]; const profileData = JSON.parse(fs.readFileSync(profilePath).toString()); return Promise.resolve().then(() => makeProfileJWT(profileData, args[1])); } /* * Verify a profile with an address or public key * @path (string) path to the profile * @publicKeyOrAddress (string) public key or address */ function profileVerify(_network: CLINetworkAdapter, args: string[]): Promise<string> { const profilePath = args[0]; let publicKeyOrAddress = args[1]; // need to coerce mainnet if (publicKeyOrAddress.match(ID_ADDRESS_PATTERN)) { publicKeyOrAddress = _network.coerceMainnetAddress(publicKeyOrAddress.slice(3)); } const profileString = fs.readFileSync(profilePath).toString(); return Promise.resolve().then(() => { let profileToken = null; try { const profileTokens = JSON.parse(profileString); profileToken = profileTokens[0].token; } catch (e) { // might be a raw token profileToken = profileString; } if (!profileToken) { throw new Error(`Data at ${profilePath} does not appear to be a signed profile`); } const profile = blockstack.extractProfile(profileToken, publicKeyOrAddress); return JSONStringify(profile); }); } /* * Store a signed profile for a name or an address. * * verify that the profile was signed by the name's owner address * * verify that the private key matches the name's owner address * * Assumes that the URI records are all Gaia hubs * * @nameOrAddress (string) name or address that owns the profile * @path (string) path to the signed profile token * @privateKey (string) owner private key for the name * @gaiaUrl (string) this is the write endpoint of the Gaia hub to use */ function profileStore(_network: CLINetworkAdapter, args: string[]): Promise<string> { const nameOrAddress = args[0]; const signedProfilePath = args[1]; const privateKey = decodePrivateKey(args[2]); const gaiaHubUrl = args[3]; const signedProfileData = fs.readFileSync(signedProfilePath).toString(); const ownerAddress = getPrivateKeyAddress(_network, privateKey); const ownerAddressMainnet = _network.coerceMainnetAddress(ownerAddress); let nameInfoPromise: Promise<NameInfoType | null>; let name = ''; if (nameOrAddress.startsWith('ID-')) { // ID-address nameInfoPromise = Promise.resolve().then(() => { return { address: nameOrAddress.slice(3), }; }); } else { // name; find the address nameInfoPromise = getNameInfoEasy(_network, nameOrAddress); name = nameOrAddress; } const verifyProfilePromise = profileVerify(_network, [ signedProfilePath, `ID-${ownerAddressMainnet}`, ]); return Promise.all([nameInfoPromise, verifyProfilePromise]) .then(([nameInfo, _verifiedProfile]: [NameInfoType | null, any]) => { if ( safetyChecks && (!nameInfo || _network.coerceAddress(nameInfo.address) !== _network.coerceAddress(ownerAddress)) ) { throw new Error( 'Name owner address either could not be found, or does not match ' + `private key address ${ownerAddress}` ); } return gaiaUploadProfileAll(_network, [gaiaHubUrl], signedProfileData, args[2], name); }) .then((gaiaUrls: { dataUrls?: string[] | null; error?: string | null }) => { if (gaiaUrls.hasOwnProperty('error')) { return JSONStringify({ dataUrls: gaiaUrls.dataUrls!, error: gaiaUrls.error! }, true); } else { return JSONStringify({ profileUrls: gaiaUrls.dataUrls! }); } }); } /* * Get the app private key(s) from a backup phrase * and an index of the enumerated accounts * args: * @mnemonic (string) the 12-word phrase * @index (number) the index of the account * @appOrigin (string) the application's origin URL */ async function getAppKeys(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic = await getBackupPhrase(args[0]); const index = parseInt(args[1]); if (index <= 0) throw new Error('index must be greater than 0'); const appDomain = args[2]; let wallet = await generateWallet({ secretKey: mnemonic, password: '' }); for (let i = 0; i < index; i++) { wallet = generateNewAccount(wallet); } const account = wallet.accounts[index - 1]; const privateKey = getAppPrivateKey({ account, appDomain }); const address = getAddressFromPrivateKey(privateKey, getStacksNetwork(_network)); return JSON.stringify({ keyInfo: { privateKey, address } }); } /* * Get the owner private key(s) from a backup phrase * args: * @mnemonic (string) the 12-word phrase * @max_index (integer) (optional) the profile index maximum */ async function getOwnerKeys(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic = await getBackupPhrase(args[0]); let maxIndex = 1; if (args.length > 1 && !!args[1]) { maxIndex = parseInt(args[1]); } const keyInfo: OwnerKeyInfoType[] = []; for (let i = 0; i < maxIndex; i++) { keyInfo.push(await getOwnerKeyInfo(_network, mnemonic, i)); } return JSONStringify(keyInfo); } /* * Get the payment private key from a backup phrase * args: * @mnemonic (string) the 12-word phrase */ async function getPaymentKey(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic = await getBackupPhrase(args[0]); // keep the return value consistent with getOwnerKeys const keyObj = await getPaymentKeyInfo(_network, mnemonic); const keyInfo: PaymentKeyInfoType[] = []; keyInfo.push(keyObj); return JSONStringify(keyInfo); } /* * Get the payment private key from a backup phrase used by the Stacks wallet * args: * @mnemonic (string) the 24-word phrase */ async function getStacksWalletKey(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic = await getBackupPhrase(args[0]); const derivationPath: string | undefined = args[1] || undefined; // keep the return value consistent with getOwnerKeys const keyObj = await getStacksWalletKeyInfo(_network, mnemonic, derivationPath); const keyInfo: StacksKeyInfoType[] = []; keyInfo.push(keyObj); return JSONStringify(keyInfo); } /** * Enable users to transfer subdomains to wallet-key addresses that correspond to all data-key addresses * Reference: https://github.com/hirosystems/stacks.js/issues/1209 * args: * @mnemonic (string) the seed phrase to retrieve the privateKey & address * @registrarUrl (string) URL of the registrar to use (defaults to 'https://registrar.stacks.co') */ async function migrateSubdomains(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic: string = await getBackupPhrase(args[0]); // args[0] is the cli argument for mnemonic const baseWallet = await generateWallet({ secretKey: mnemonic, password: '' }); const network = getStacksNetwork(_network); const wallet = await restoreWalletAccounts({ wallet: baseWallet, gaiaHubUrl: 'https://hub.blockstack.org', network, }); console.log( `Accounts found: ${wallet.accounts.length}\n(Accounts will be checked for both compressed and uncompressed public keys)` ); const payload = { subdomains_list: <SubdomainOp[]>[] }; // Payload required by transfer endpoint const accounts = wallet.accounts .map(account => [ // Duplicate accounts (taking once as uncompressed, once as compressed) { ...account, dataPrivateKey: account.dataPrivateKey }, { ...account, dataPrivateKey: account.dataPrivateKey + '01' }, ]) .flat(); for (const account of accounts) { console.log('\nAccount:', account); const dataKeyAddress = getAddressFromPrivateKey(account.dataPrivateKey, network); // source const walletKeyAddress = getAddressFromPrivateKey(account.stxPrivateKey, network); // target console.log(`Finding subdomains for data-key address '${dataKeyAddress}'`); const namesResponse = await fetch( `${defaultUrlFromNetwork(network)}/v1/addresses/stacks/${dataKeyAddress}` ); const namesJson = await namesResponse.json(); if ((namesJson.names?.length || 0) <= 0) { console.log(`No subdomains found for address '${dataKeyAddress}'`); continue; } const regExp = /(\..*){2,}/; // has two or more dots somewhere const subDomains = namesJson.names.filter((val: string) => regExp.test(val)); if (subDomains.length === 0) console.log(`No subdomains found for address '${dataKeyAddress}'`); for (const subdomain of subDomains) { // Alerts the user to any subdomains that can't be migrated to these wallet-key-derived addresses // Given collision with existing usernames owned by them const namesResponse = await fetch( `${defaultUrlFromNetwork(network)}/v1/addresses/stacks/${walletKeyAddress}` ); const existingNames = await namesResponse.json(); if (existingNames.names?.includes(subdomain)) { console.log(`Error: Subdomain '${subdomain}' already exists in wallet-key address.`); continue; } // Validate user owns the subdomain const nameInfo = await fetch(`${defaultUrlFromNetwork(network)}/v1/names/${subdomain}`); const nameInfoJson = await nameInfo.json(); console.log('Subdomain Info: ', nameInfoJson); if (nameInfoJson.address !== dataKeyAddress) { console.log(`Error: The account is not the owner of the subdomain '${subdomain}'`); continue; } const promptName = subdomain.replaceAll('.', '_'); // avoid confusing with nested prompt response const confirmMigration: { [promptName: string]: string } = await prompt([ { name: promptName, message: `Do you want to migrate the domain '${subdomain}'`, type: 'confirm', }, ]); // On 'NO', move to next account if (!confirmMigration[promptName]) continue; // Prepare migration operation const [subdomainName] = subdomain.split('.'); // registrar expects only the first part of a subdomain const subDomainOp: SubdomainOp = { subdomainName, owner: walletKeyAddress, // new owner address / wallet-key address (compressed) zonefile: nameInfoJson.zonefile, sequenceNumber: 1, // should be 'old sequence number + 1', but cannot find old sequence number so assuming 1. client should calculate it again. }; const subdomainPieces = subdomainOpToZFPieces(subDomainOp); const textToSign = subdomainPieces.txt.join(','); // Generate signature: https://docs.stacks.co/build-apps/references/bns#subdomain-lifecycle /** * *********************** IMPORTANT ********************************************** * If the subdomain owner wants to change the address of their subdomain, * * they need to sign a subdomain-transfer operation and * * give it to the on-chain name owner who created the subdomain. * * They then package it into a zone file and broadcast it. * * *********************** IMPORTANT ********************************************** * subdomain operation will only be accepted if it has a later "sequence=" number,* * and a valid signature in "sig=" over the transaction body .The "sig=" field * * includes both the public key and signature, and the public key must hash to * * the previous subdomain operation's "addr=" field * * ******************************************************************************** */ const hash = crypto.createHash('sha256').update(textToSign).digest('hex'); const sig = signWithKey(account.dataPrivateKey, hash); // https://docs.stacks.co/build-apps/references/bns#subdomain-lifecycle subDomainOp.signature = sig; payload.subdomains_list.push(subDomainOp); } } console.log('\nSubdomain Operation Payload:', payload); if (payload.subdomains_list.length <= 0) { return '"No subdomains found or selected. Canceling..."'; } // Subdomains batch migration // Payload contains list of subdomains that user opted for migration const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }; // args[1] is the cli argument for registrarUrl to optionally replace default url const registrarUrl = args[1] || 'https://registrar.stacks.co'; const migrationURL = `${registrarUrl}/transfer`; console.log('Sending migration request...'); return fetch(migrationURL, options) .then(response => { if (response.status === 404) { return Promise.reject({ status: response.status, error: response.statusText, }); } return response.json(); }) .then(response => { if (response.txid) console.log( `The transaction will take some time to complete. Track its progress using the explorer: https://explorer.hiro.so/txid/0x${response.txid}` ); return Promise.resolve(JSONStringify(response)); }) .catch(error => error); } /* * Make a private key and output it * args: * @mnemonic (string) OPTIONAL; the 12-word phrase */ async function makeKeychain(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic: string = args[0] ? await getBackupPhrase(args[0]) : scureBip39.generateMnemonic(wordlist, STX_WALLET_COMPATIBLE_SEED_STRENGTH); const derivationPath: string | undefined = args[1] || undefined; const stacksKeyInfo = await getStacksWalletKeyInfo(_network, mnemonic, derivationPath); return JSONStringify({ mnemonic, keyInfo: stacksKeyInfo, }); } /* * Get an address's tokens and their balances. * Takes either a Bitcoin or Stacks address * args: * @address (string) the address */ function balance(_network: CLINetworkAdapter, args: string[]): Promise<string> { let address = args[0]; if (BLOCKSTACK_TEST) { // force testnet address if we're in testnet mode address = _network.coerceAddress(address); } const url = _network.nodeAPIUrl; return fetch(`${url}${ACCOUNT_PATH}/${address}?proof=0`) .then(response => { if (response.status === 404) { return Promise.reject({ status: response.status, error: response.statusText, }); } return response.json(); }) .then(response => { const res = { balance: BigInt(response.balance).toString(10), locked: BigInt(response.locked).toString(10), unlock_height: response.unlock_height, nonce: response.nonce, }; return Promise.resolve(JSONStringify(res)); }) .catch(error => error); } /* * Get a page of the account's history * args: * @address (string) the account address * @page (int) the page of the history to fetch (optional) */ function getAccountHistory(_network: CLINetworkAdapter, args: string[]): Promise<string> { const address = c32check.c32ToB58(args[0]); if (args.length >= 2 && !!args[1]) { const page = parseInt(args[1]); return Promise.resolve() .then(() => { return _network.getAccountHistoryPage(address, page); }) .then(accountStates => JSONStringify( accountStates.map((s: any) => { const new_s = { address: c32check.b58ToC32(s.address), credit_value: s.credit_value.toString(), debit_value: s.debit_value.toString(), }; return new_s; }) ) ); } else { // all pages let history: any[] = []; function getAllAccountHistoryPages(page: number): Promise<any[]> { return _network.getAccountHistoryPage(address, page).then((results: any[]) => { if (results.length == 0) { return history; } else { history = history.concat(results); return getAllAccountHistoryPages(page + 1); } }); } return getAllAccountHistoryPages(0).then((accountStates: any[]) => JSONStringify( accountStates.map((s: any) => { const new_s = { address: c32check.b58ToC32(s.address), credit_value: s.credit_value.toString(), debit_value: s.debit_value.toString(), }; return new_s; }) ) ); } } // /* // * Get the account's state(s) at a particular block height // * args: // * @address (string) the account address // * @blockHeight (int) the height at which to query // */ // function getAccountAt(network: CLINetworkAdapter, args: string[]) : Promise<string> { // const address = c32check.c32ToB58(args[0]); // const blockHeight = parseInt(args[1]); // return Promise.resolve().then(() => { // return network.getAccountAt(address, blockHeight); // }) // .then(accountStates => accountStates.map((s : any) => { // const new_s = { // address: c32check.b58ToC32(s.address), // credit_value: s.credit_value.toString(), // debit_value: s.debit_value.toString() // }; // return new_s; // })) // .then(history => JSONStringify(history)); // } // /* // * Sends BTC from one private key to another address // * args: // * @recipientAddress (string) the recipient's address // * @amount (string) the amount of BTC to send // * @privateKey (string) the private key that owns the BTC // */ // function sendBTC(network: CLINetworkAdapter, args: string[]) : Promise<string> { // const destinationAddress = args[0]; // const amount = parseInt(args[1]); // const paymentKeyHex = decodePrivateKey(args[2]); // if (amount <= 5500) { // throw new Error('Invalid amount (must be greater than 5500)'); // } // let paymentKey; // if (typeof paymentKeyHex === 'string') { // // single-sig // paymentKey = blockstack.PubkeyHashSigner.fromHexString(paymentKeyHex); // } // else { // // multi-sig or segwit // paymentKey = paymentKeyHex; // } // const txPromise = blockstack.transactions.makeBitcoinSpend(destinationAddress, paymentKey, amount, !hasKeys(paymentKeyHex)) // .catch((e : Error) => { // if (e.name === 'InvalidAmountError') { // return JSONStringify({ // 'status': false, // 'error': e.message // }, true); // } // else { // throw e; // } // }); // if (txOnly) { // return txPromise; // } // else { // return txPromise.then((tx : string) => { // return network.broadcastTransaction(tx); // }) // .then((txid : string) => { // return txid; // }); // } // } /* * Send tokens from one account private key to another account's address. * args: * @recipientAddress (string) the recipient's account address * @tokenAmount (int) the number of tokens to send * @fee (int) the transaction fee to be paid * @nonce (int) integer nonce needs to be incremented after each transaction from an account * @privateKey (string) the hex-encoded private key to use to send the tokens * @memo (string) OPTIONAL: a 34-byte memo to include */ async function sendTokens(_network: CLINetworkAdapter, args: string[]): Promise<string> { const recipientAddress = args[0]; const tokenAmount = BigInt(args[1]); const fee = BigInt(args[2]); const nonce = BigInt(args[3]); const privateKey = args[4]; let memo = ''; if (args.length > 4 && !!args[5]) { memo = args[5]; } const network = getStacksNetwork(_network); const options: SignedTokenTransferOptions = { recipient: recipientAddress, amount: tokenAmount, senderKey: privateKey, fee, nonce, memo, network, }; const tx: StacksTransactionWire = await makeSTXTokenTransfer(options); if (estimateOnly) { return fetchFeeEstimateTransfer({ transaction: tx, network }).then(cost => { return cost.toString(10); }); } if (txOnly) { return Promise.resolve(tx.serialize()); } return broadcastTransaction({ transaction: tx, network }) .then((response: TxBroadcastResult) => { if (response.hasOwnProperty('error')) { return response; } return { txid: `0x${tx.txid()}`, transaction: generateExplorerTxPageUrl(tx.txid(), network), }; }) .catch(error => { return error.toString(); }); } /* * Depoly a Clarity smart contract. * args: * @source (string) path to the contract source file * @contractName (string) the name of the contract * @fee (int) the transaction fee to be paid * @nonce (int) integer nonce needs to be incremented after each transaction from an account * @privateKey (string) the hex-encoded private key to use to send the tokens */ async function contractDeploy(_network: CLINetworkAdapter, args: string[]): Promise<string> { const sourceFile = args[0]; const contractName = args[1]; const fee = BigInt(args[2]); const nonce = BigInt(args[3]); const privateKey = args[4]; const source = fs.readFileSync(sourceFile).toString(); const network = getStacksNetwork(_network); const options: SignedContractDeployOptions = { contractName, codeBody: source, senderKey: privateKey, fee, nonce, network, postConditionMode: 'allow', }; const tx = await makeContractDeploy(options); if (estimateOnly) { return fetchFeeEstimateTransaction({ payload: serializePayload(tx.payload), estimatedLength: estimateTransactionByteLength(tx), }).then(costs => costs[1].fee.toString(10)); } if (txOnly) { return Promise.resolve(tx.serialize()); } return broadcastTransaction({ transaction: tx }) .then(response => { if (response.hasOwnProperty('error')) { return response; } return { txid: `0x${tx.txid()}`, transaction: generateExplorerTxPageUrl(tx.txid(), network), }; }) .catch(error => { return error.toString(); }); } /** @internal */ export function parseDirectFunctionArgs(functionArgsStr: string): ClarityValue[] { return internal_parseCommaSeparated(functionArgsStr); } // Get function arguments via interactive prompts async function getInteractiveFunctionArgs(abiArgs: ClarityFunctionArg[]): Promise<ClarityValue[]> { const prompts = makePromptsFromArgList(abiArgs); const answers = await prompt(prompts); return parseClarityFunctionArgAnswers(answers, abiArgs); } /* * Call a Clarity smart contract function. * args: * @contractAddress (string) the address of the contract * @contractName (string) the name of the contract * @functionName (string) the name of the function to call * @fee (int) the transaction fee to be paid * @nonce (int) integer nonce needs to be incremented after each transaction from an account * @privateKey (string) the hex-encoded private key to use to send the tokens */ async function contractFunctionCall(_network: CLINetworkAdapter, args: string[]): Promise<string> { const contractAddress = args[0]; const contractName = args[1]; const functionName = args[2]; const fee = BigInt(args[3]); const nonce = BigInt(args[4]); const privateKey = args[5]; const functionArgsStr = args.length > 6 ? args[6] : undefined; const network = getStacksNetwork(_network); const abi = await fetchAbi({ contractAddress, contractName, network }); const filteredFn = abi.functions.filter(fn => fn.name === functionName); if (filteredFn.length !== 1) { throw new Error(`Function ${functionName} not found in contract ${contractName}`); } const abiArgs = filteredFn[0].args; const functionArgs = functionArgsStr ? parseDirectFunctionArgs(functionArgsStr) : await getInteractiveFunctionArgs(abiArgs); const payload = createContractCallPayload( contractAddress, contractName, functionName, functionArgs ); validateContractCall(payload, abi); const options: SignedContractCallOptions = { contractAddress, contractName, functionName, functionArgs, senderKey: privateKey, fee, nonce, network, postConditionMode: PostConditionMode.Allow, }; const tx = await makeContractCall(options); if (!validateContractCall(tx.payload as ContractCallPayload, abi)) { throw new Error('Failed to validate function arguments against ABI'); } if (estimateOnly) { const costs = await fetchFeeEstimateTransaction({ payload: serializePayload(tx.payload), estimatedLength: estimateTransactionByteLength(tx), }); return costs[1].fee.toString(10); } if (txOnly) return tx.serialize(); try { const response = await broadcastTransaction({ transaction: tx, network }); if (response.hasOwnProperty('error')) return JSONStringify(response); return JSONStringify({ txid: `0x${tx.txid()}`, transaction: generateExplorerTxPageUrl(tx.txid(), network), }); } catch (error) { if (error instanceof Error) return error.message; return 'Unknown error occurred'; } } /* * Call a read-only Clarity smart contract function. * args: * @contractAddress (string) the address of the contract * @contractName (string) the name of the contract * @functionName (string) the name of the function to call * @senderAddress (string) the sender address */ async function readOnlyContractFunctionCall( _network: CLINetworkAdapter, args: string[] ): Promise<string> { const contractAddress = args[0]; const contractName = args[1]; const functionName = args[2]; const senderAddress = args[3]; const network = getStacksNetwork(_network); let abi: ClarityAbi; let abiArgs: ClarityFunctionArg[]; let functionArgs: ClarityValue[] = []; return fetchAbi({ contractAddress, contractName, network }) .then(responseAbi => { abi = responseAbi; const filtered = abi.functions.filter(fn => fn.name === functionName); if (filtered.length === 1) { abiArgs = filtered[0].args; return makePromptsFromArgList(abiArgs); } else { return null; } }) .then(prompts => prompt(prompts!)) .then(answers => { functionArgs = parseClarityFunctionArgAnswers(answers, abiArgs); const options: ReadOnlyFunctionOptions = { contractAddress, contractName, functionName, functionArgs, senderAddress, network, }; return fetchCallReadOnlyFunction(options); }) .then(returnValue => { return cvToString(returnValue); }) .catch(error => { return error.toString(); }); } /* * Decode a serialized Clarity value * args: * @value (string) the hex string of the serialized value, or '-' to read from stdin * @format (string) the format to output the value in; one of 'pretty', 'json', or 'repr' */ function decodeCV(_network: CLINetworkAdapter, args: string[]): Promise<string> { const inputArg = args[0]; const format = args[1]; let inputValue: string; if (inputArg === '-') { inputValue = fs.readFileSync(process.stdin.fd, 'utf-8').trim(); } else { inputValue = inputArg; } const cv = Cl.deserialize(inputValue); let cvString: string; if (format === 'pretty') { cvString = Cl.prettyPrint(cv, 2); } else if (format === 'json') { cvString = JSON.stringify(cvToJSON(cv)); } else if (format === 'repr' || !format) { cvString = cvToString(cv); } else { throw new Error('Invalid format option'); } return Promise.resolve(cvString); } // /* // * Get the number of confirmations of a txid. // * args: // * @txid (string) the transaction ID as a hex string // */ // function getConfirmations(network: CLINetworkAdapter, args: string[]) : Promise<string> { // const txid = args[0]; // return Promise.all([network.getBlockHeight(), network.getTransactionInfo(txid)]) // .then(([blockHeight, txInfo]) => { // return JSONStringify({ // 'blockHeight': txInfo.block_height, // 'confirmations': blockHeight - txInfo.block_height + 1 // }); // }) // .catch((e) => { // if (e.message.toLowerCase() === 'unconfirmed transaction') { // return JSONStringify({ // 'blockHeight': 'unconfirmed', // 'confirmations': 0 // }); // } // else { // throw e; // } // }); // } /* * Get the address of a private key * args: * @private_key (string) the hex-encoded private key or key bundle */ function getKeyAddress(_network: CLINetworkAdapter, args: string[]): Promise<string> { const privateKey = decodePrivateKey(args[0]); return Promise.resolve().then(() => { const addr = getPrivateKeyAddress(_network, privateKey); return JSONStringify({ BTC: addr, STACKS: c32check.b58ToC32(addr), }); }); } /* * Get a file from Gaia. * args: * @username (string) the blockstack ID of the user who owns the data * @origin (string) the application origin * @path (string) the file to read * @appPrivateKey (string) OPTIONAL: the app private key to decrypt/verify with * @decrypt (string) OPTINOAL: if '1' or 'true', then decrypt * @verify (string) OPTIONAL: if '1' or 'true', then search for and verify a signature file * along with the data */ function gaiaGetFile(_network: CLINetworkAdapter, args: string[]): Promise<string | Buffer> { const username = args[0]; const origin = args[1]; const path = args[2]; let appPrivateKey = args[3]; let decrypt = false; let verify = false; if (!!appPrivateKey && args.length > 4 && !!args[4]) { decrypt = args[4].toLowerCase() === 'true' || args[4].toLowerCase() === '1'; } if (!!appPrivateKey && args.length > 5 && !!args[5]) { verify = args[5].toLowerCase() === 'true' || args[5].toLowerCase() === '1'; } if (!appPrivateKey) { // make a fake private key (it won't be used) appPrivateKey = 'fda1afa3ff9ef25579edb5833b825ac29fae82d03db3f607db048aae018fe882'; } // force mainnet addresses blockstack.config.network.layer1 = bitcoin.networks.bitcoin; return gaiaAuth(_network, appPrivateKey, null) .then((_userData: UserData) => blockstack.getFile(path, { decrypt: decrypt, verify: verify, app: origin, username: username, }) ) .then((data: ArrayBuffer | Buffer | string) => { if (data instanceof ArrayBuffer) { return Buffer.from(data); } else { return data; } }); } /* * Put a file into a Gaia hub * args: * @hubUrl (string) the URL to the write endpoint of the gaia hub * @appPrivateKey (string) the private key used to authenticate to the gaia hub * @dataPath (string) the path (on disk) to the data to store * @gaiaPath (string) the path (in Gaia) where the data will be stored * @encrypt (string) OPTIONAL: if '1' or 'true', then encrypt the file * @sign (string) OPTIONAL: if '1' or 'true', then sign the file and store the signature too. */ function gaiaPutFile(_network: CLINetworkAdapter, args: string[]): Promise<string> { const hubUrl = args[0]; const appPrivateKey = args[1]; const dataPath = args[2]; const gaiaPath = path.normalize(args[3].replace(/^\/+/, '')); let encrypt = false; let sign = false; if (args.length > 4 && !!args[4]) { encrypt = args[4].toLowerCase() === 'true' || args[4].toLowerCase() === '1'; } if (args.length > 5 && !!args[5]) { sign = args[5].toLowerCase() === 'true' || args[5].toLowerCase() === '1'; } const data = fs.readFileSync(dataPath); // force mainnet addresses // TODO blockstack.config.network.layer1 = bitcoin.networks.bitcoin; return gaiaAuth(_network, appPrivateKey, hubUrl) .then((_userData: UserData) => { return blockstack.putFile(gaiaPath, data, { encrypt: encrypt, sign: sign }); }) .then((url: string) => { return JSONStringify({ urls: [url] }); }); } /* * Delete a file in a Gaia hub * args: * @hubUrl (string) the URL to the write endpoint of the gaia hub * @appPrivateKey (string) the private key used to authenticate to the gaia hub * @gaiaPath (string) the path (in Gaia) to delete * @wasSigned (string) OPTIONAL: if '1' or 'true'. Delete the signature file as well. */ function gaiaDeleteFile(_network: CLINetworkAdapter, args: string[]): Promise<string> { const hubUrl = args[0]; const appPrivateKey = args[1]; const gaiaPath = path.normalize(args[2].replace(/^\/+/, '')); let wasSigned = false; if (args.length > 3 && !!args[3]) { wasSigned = args[3].toLowerCase() === 'true' || args[3].toLowerCase() === '1'; } // force mainnet addresses // TODO blockstack.config.network.layer1 = bitcoin.networks.bitcoin; return gaiaAuth(_network, appPrivateKey, hubUrl) .then((_userData: UserData) => { return blockstack.deleteFile(gaiaPath, { wasSigned: wasSigned }); }) .then(() => { return JSONStringify('ok'); }); } /* * List files in a Gaia hub * args: * @hubUrl (string) the URL to the write endpoint of the gaia hub * @appPrivateKey (string) the private key used to authenticate to the gaia hub */ function gaiaListFiles(_network: CLINetworkAdapter, args: string[]): Promise<string> { const hubUrl = args[0]; const appPrivateKey = args[1]; // force mainnet addresses // TODO let count = 0; blockstack.config.network.layer1 = bitcoin.networks.bitcoin; return gaiaAuth(_network, canonicalPrivateKey(appPrivateKey), hubUrl) .then((_userData: UserData) => { return blockstack.listFiles((name: string) => { // print out incrementally console.log(name); count += 1; return true; }); }) .then(() => JSONStringify(count)); } /* * Group array items into batches */ function batchify<T>(input: T[], batchSize: number = 50): T[][] { const output = []; let currentBatch = []; for (let i = 0; i < input.length; i++) { currentBatch.push(input[i]); if (currentBatch.length >= batchSize) { output.push(currentBatch); currentBatch = []; } } if (currentBatch.length > 0) { output.push(currentBatch); } return output; } /** * Sleep for a number of milliseconds. */ function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } /* * Dump all files from a Gaia hub bucket to a directory on disk. * args: * @nameOrIDAddress (string) the name or ID address that owns the bucket to dump * @appOrigin (string) the application for which to dump data * @hubUrl (string) the URL to the write endpoint of the gaia hub * @mnemonic (string) the 12-word phrase or ciphertext * @dumpDir (string) the directory to hold the dumped files */ function gaiaDumpBucket(_network: CLINetworkAdapter, args: string[]): Promise<string> { const nameOrIDAddress = args[0]; const appOrigin = args[1]; const hubUrl = args[2]; const mnemonicOrCiphertext = args[3]; let dumpDir = args[4]; if (dumpDir.length === 0) { throw new Error('Invalid directory (not given)'); } if (dumpDir[0] !== '/') { // relative path. make absolute const cwd = fs.realpathSync('.'); dumpDir = path.normalize(`${cwd}/${dumpDir}`); } mkdirs(dumpDir); function downloadFile(hubConfig: GaiaHubConfig, fileName: string): Promise<void> { const gaiaReadUrl = `${hubConfig.url_prefix.replace(/\/+$/, '')}/${hubConfig.address}`; const fileUrl = `${gaiaReadUrl}/${fileName}`; const destPath = `${dumpDir}/${fileName.replace(/\//g, '\\x2f')}`; console.log(`Download ${fileUrl} to ${destPath}`); return fetch(fileUrl) .then((resp: any) => { if (resp.status !== 200) { throw new Error(`Bad status code for ${fileUrl}: ${resp.status}`); } // javascript can be incredibly stupid at fetching data despite being a Web language... const contentType = resp.headers.get('Content-Type'); if ( contentType === null || contentType.startsWith('text') || contentType === 'application/json' ) { return resp.text(); } else { return resp.arrayBuffer(); } }) .then((filebytes: Buffer | ArrayBuffer) => { return new Promise((resolve, reject) => { try { fs.writeFileSync(destPath, Buffer.from(filebytes), { encoding: null, mode: 0o660 }); resolve(); } catch (e) { reject(e); } }); }); } // force mainnet addresses // TODO: better way of doing this blockstack.config.network.layer1 = bitcoin.networks.bitcoin; const fileNames: string[] = []; let gaiaHubConfig: GaiaHubConfig; let appPrivateKey: string; let ownerPrivateKey: string; return getIDAppKeys(_network, nameOrIDAddress, appOrigin, mnemonicOrCiphertext) .then((keyInfo: IDAppKeys) => { appPrivateKey = keyInfo.appPrivateKey; ownerPrivateKey = keyInfo.ownerPrivateKey; return gaiaAuth(_network, appPrivateKey, hubUrl, ownerPrivateKey); }) .then((_userData: UserData) => { return gaiaConnect(_network, hubUrl, appPrivateKey); }) .then((hubConfig: GaiaHubConfig) => { gaiaHubConfig = hubConfig; return blockstack.listFiles(name => { fileNames.push(name); return true; }); }) .then(async (fileCount: number) => { // rate limit is 100rpm const batchSize = 99; const sleepTime = 120; console.log(`Download ${fileCount} files...`); if (fileCount > batchSize) { console.log( `This may take a while, downloading around ${batchSize} files per 2 minutes...` ); } const fileBatches: string[][] = batchify(fileNames, batchSize); for (const [index, batch] of fileBatches.entries()) { const filePromises = batch.map(fileName => downloadFile(gaiaHubConfig, fileName)); await Promise.all(filePromises); if (index < fileBatches.length - 1) { console.log( `${ (index + 1) * batchSize }/${fileCount} downloaded, waiting ${sleepTime} seconds before next batch...` ); await sleep(sleepTime * 1000); } } return JSONStringify(fileCount); }); } /* * Restore all of the files in a Gaia bucket dump to a new Gaia hub * args: * @nameOrIDAddress (string) the name or ID address that owns the bucket to dump * @appOrigin (string) the origin of the app for which to restore data * @hubUrl (string) the URL to the write endpoint of the new gaia hub * @mnemonic (string) the 12-word phrase or ciphertext * @dumpDir (string) the directory to hold the dumped files */ function gaiaRestoreBucket(_network: CLINetworkAdapter, args: string[]): Promise<string> { const nameOrIDAddress = args[0]; const appOrigin = args[1]; const hubUrl = args[2]; const mnemonicOrCiphertext = args[3]; let dumpDir = args[4]; if (dumpDir.length === 0) { throw new Error('Invalid directory (not given)'); } if (dumpDir[0] !== '/') { // relative path. make absolute const cwd = fs.realpathSync('.'); dumpDir = path.normalize(`${cwd}/${dumpDir}`); } const fileList = fs.readdirSync(dumpDir); const fileBatches = batchify(fileList, 10); let appPrivateKey: string; let ownerPrivateKey: string; // force mainnet addresses // TODO better way of doing this blockstack.config.network.layer1 = bitcoin.networks.bitcoin; return getIDAppKeys(_network, nameOrIDAddress, appOrigin, mnemonicOrCiphertext) .then((keyInfo: IDAppKeys) => { appPrivateKey = keyInfo.appPrivateKey; ownerPrivateKey = keyInfo.ownerPrivateKey; return gaiaAuth(_network, appPrivateKey, hubUrl, ownerPrivateKey); }) .then(async (_userData: UserData) => { const batchSize = 99; const sleepTime = 120; for (const [index, batch] of fileBatches.entries()) { const uploadBatchPromises = batch.map(async (fileName: string) => { const filePath = path.join(dumpDir, fileName); const dataBuf = fs.readFileSync(filePath); const gaiaPath = fileName.replace(/\\x2f/g, '/'); const url = await blockstack.putFile(gaiaPath, dataBuf, { encrypt: false, sign: false }); console.log(`Uploaded ${fileName} to ${url}`); }); await Promise.all(uploadBatchPromises); if (index < fileBatches.length - 1) { console.log( `${(index + 1) * batchSize}/${ fileList.length } uploaded, waiting ${sleepTime} seconds before next batch...` ); await sleep(sleepTime * 1000); } } return JSONStringify(fileList.length); }); } /* * Set the Gaia hub for an application for a blockstack ID. * args: * @blockstackID (string) the blockstack ID of the user * @profileHubUrl (string) the URL to the write endpoint of the user's profile gaia hub * @appOrigin (string) the application's Origin * @hubUrl (string) the URL to the write endpoint of the app's gaia hub * @mnemonic (string) the 12-word backup phrase, or the ciphertext of it */ async function gaiaSetHub(_network: CLINetworkAdapter, args: string[]): Promise<string> { _network.setCoerceMainnetAddress(true); const blockstackID = args[0]; const ownerHubUrl = args[1]; const appOrigin = args[2]; const hubUrl = args[3]; const mnemonicPromise = getBackupPhrase(args[4]); const nameInfoPromise = getNameInfoEasy(_network, blockstackID).then( (nameInfo: NameInfoType | null) => { if (!nameInfo) { throw new Error('Name not found'); } return nameInfo; } ); const profilePromise = blockstack.lookupProfile(blockstackID); const [nameInfo, nameProfile, mnemonic]: [NameInfoType, any, string] = await Promise.all([ nameInfoPromise, profilePromise, mnemonicPromise, ]); if (!nameProfile) { throw new Error('No profile found'); } if (!nameInfo) { throw new Error('Name not found'); } if (!nameInfo.zonefile) { throw new Error('No zone file found'); } if (!nameProfile.apps) { nameProfile.apps = {}; } // get owner ID-address const ownerAddress = _network.coerceMainnetAddress(nameInfo.address); const idAddress = `ID-${ownerAddress}`; // get owner and app key info const appKeyInfo = await getApplicationKeyInfo(_network, mnemonic, idAddress, appOrigin); const ownerKeyInfo = await getOwnerKeyInfo(_network, mnemonic, appKeyInfo.ownerKeyIndex); // do we already have an address set for this app? let existingAppAddress: string | null = null; let appPrivateKey: string; try { existingAppAddress = getGaiaAddressFromProfile(_network, nameProfile, appOrigin); appPrivateKey = extractAppKey(_network, appKeyInfo, existingAppAddress); } catch (e) { console.log(`No profile application entry for ${appOrigin}`); appPrivateKey = extractAppKey(_network, appKeyInfo); } appPrivateKey = `${canonicalPrivateKey(appPrivateKey)}01`; const appAddress = _network.coerceMainnetAddress(getPrivateKeyAddress(_network, appPrivateKey)); if (existingAppAddress && appAddress !== existingAppAddress) { throw new Error(`BUG: ${existingAppAddress} !== ${appAddress}`); } const profile = nameProfile; const ownerPrivateKey = ownerKeyInfo.privateKey; const ownerGaiaHubPromise = gaiaConnect(_network, ownerHubUrl, ownerPrivateKey); const appGaiaHubPromise = gaiaConnect(_network, hubUrl, appPrivateKey); const [ownerHubConfig, appHubConfig]: [GaiaHubConfig, GaiaHubConfig] = await Promise.all([ ownerGaiaHubPromise, appGaiaHubPromise, ]); if (!ownerHubConfig.url_prefix) { throw new Error('Invalid owner hub config: no url_prefix defined'); } if (!appHubConfig.url_prefix) { throw new Error('Invalid app hub config: no url_prefix defined'); } const gaiaReadUrl = appHubConfig.url_prefix.replace(/\/+$/, ''); const newAppEntry: Record<string, string> = {}; newAppEntry[appOrigin] = `${gaiaReadUrl}/${appAddress}/`; const apps = Object.assign({}, profile.apps ? profile.apps : {}, newAppEntry); profile.apps = apps; // sign the new profile const signedProfile = makeProfileJWT(profile, ownerPrivateKey); const profileUrls: { dataUrls?: string[] | null; error?: string | null; } = await gaiaUploadProfileAll( _network, [ownerHubUrl], signedProfile, ownerPrivateKey, blockstackID ); if (profileUrls.error) { return JSONStringify({ error: profileUrls.error, }); } else { return JSONStringify({ profileUrls: profileUrls.dataUrls!, }); } } /* * Convert an address between mainnet and testnet, and between * base58check and c32check. * args: * @address (string) the input address. can be in any format */ function addressConvert(_network: CLINetworkAdapter, args: string[]): Promise<string> { const addr = args[0]; let b58addr: string; let testnetb58addr: string; if (addr.match(STACKS_ADDRESS_PATTERN)) { b58addr = c32check.c32ToB58(addr); } else if (addr.match(/[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+/)) { b58addr = addr; } else { throw new Error(`Unrecognized address ${addr}`); } if (isTestnetAddress(b58addr)) { testnetb58addr = b58addr; } else if (_network.isTestnet()) { testnetb58addr = _network.coerceAddress(b58addr); } return Promise.resolve().then(() => { const mainnetb58addr = _network.coerceMainnetAddress(b58addr); const result: any = { mainnet: { STACKS: c32check.b58ToC32(mainnetb58addr), BTC: mainnetb58addr, }, testnet: undefined, }; if (testnetb58addr) { result.testnet = { STACKS: c32check.b58ToC32(testnetb58addr), BTC: testnetb58addr, }; } return JSONStringify(result); }); } /* * Encrypt a backup phrase * args: * @backup_phrase (string) the 12-word phrase to encrypt * @password (string) the password (will be interactively prompted if not given) */ // TODO: fix: network is never used // @ts-ignore function encryptMnemonic(_network: CLINetworkAdapter, args: string[]): Promise<string> { const mnemonic = args[0]; if (mnemonic.split(/ +/g).length !== 12) { throw new Error('Invalid backup phrase: must be 12 words'); } const passwordPromise: Prom