mnee
Version:
Legacy package for interacting with MNEE USD stablecoin. Includes experimental features.
1 lines • 227 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/utils/stacklessError.ts","../src/mneeCosignTemplate.ts","../src/utils/applyInscription.ts","../src/constants.ts","../src/utils/helper.ts","../src/utils/networkError.ts","../src/batch.ts","../src/mneeService.ts","../src/hdWallet.ts","../src/index.ts"],"sourcesContent":["/**\n * Creates an Error object without a stack trace\n * @param message The error message\n * @returns Error object with no stack trace\n */\nexport function stacklessError(message: string): Error {\n const error = new Error(message);\n error.stack = undefined;\n return error;\n}","import {\n Hash,\n LockingScript,\n OP,\n type PrivateKey,\n type PublicKey,\n type Script,\n type ScriptTemplate,\n type Transaction,\n TransactionSignature,\n UnlockingScript,\n Utils,\n} from \"@bsv/sdk\";\nimport { stacklessError } from \"./utils/stacklessError\";\n\n/**\n * P2PKH (Pay To Public Key Hash) class implementing ScriptTemplate.\n *\n * This class provides methods to create Pay To Public Key Hash locking and unlocking scripts, including the unlocking of P2PKH UTXOs with the private key.\n */\nexport default class CosignTemplate implements ScriptTemplate {\n /**\n * Creates a P2PKH locking script for a given public key hash or address string\n *\n * @param {number[] | string} userPKHash or address - An array or address representing the public key hash of the owning user.\n * @param {PublicKey} approverPubKey - Public key of the approver.\n * @returns {LockingScript} - A P2PKH locking script.\n */\n lock(\n userPKHash: string | number[],\n approverPubKey: PublicKey\n ): LockingScript {\n let pkhash: number[] = [];\n if (typeof userPKHash === \"string\") {\n const hash = Utils.fromBase58Check(userPKHash);\n if (hash.prefix[0] !== 0x00 && hash.prefix[0] !== 0x6f)\n throw stacklessError(\"only P2PKH is supported\");\n pkhash = hash.data as number[];\n } else {\n pkhash = userPKHash;\n }\n const lockingScript = new LockingScript();\n lockingScript\n .writeOpCode(OP.OP_DUP)\n .writeOpCode(OP.OP_HASH160)\n .writeBin(pkhash)\n .writeOpCode(OP.OP_EQUALVERIFY)\n .writeOpCode(OP.OP_CHECKSIGVERIFY)\n .writeBin(approverPubKey.encode(true) as number[])\n .writeOpCode(OP.OP_CHECKSIG);\n\n return lockingScript;\n }\n\n /**\n * Creates a function that generates a P2PKH unlocking script along with its signature and length estimation.\n *\n * The returned object contains:\n * 1. `sign` - A function that, when invoked with a transaction and an input index,\n * produces an unlocking script suitable for a P2PKH locked output.\n * 2. `estimateLength` - A function that returns the estimated length of the unlocking script in bytes.\n *\n * @param {PrivateKey} userPrivateKey - The private key used for signing the transaction.\n * @param {'all'|'none'|'single'} signOutputs - The signature scope for outputs.\n * @param {boolean} anyoneCanPay - Flag indicating if the signature allows for other inputs to be added later.\n * @param {number} sourceSatoshis - Optional. The amount being unlocked. Otherwise the input.sourceTransaction is required.\n * @param {Script} lockingScript - Optional. The lockinScript. Otherwise the input.sourceTransaction is required.\n * @returns {Object} - An object containing the `sign` and `estimateLength` functions.\n */\n userUnlock(\n userPrivateKey: PrivateKey,\n signOutputs: \"all\" | \"none\" | \"single\" = \"all\",\n anyoneCanPay = false,\n sourceSatoshis?: number,\n lockingScript?: Script\n ): {\n sign: (tx: Transaction, inputIndex: number) => Promise<UnlockingScript>;\n estimateLength: () => Promise<182>;\n } {\n return {\n sign: async (tx: Transaction, inputIndex: number) => {\n let signatureScope = TransactionSignature.SIGHASH_FORKID;\n if (signOutputs === \"all\") {\n signatureScope |= TransactionSignature.SIGHASH_ALL;\n }\n if (signOutputs === \"none\") {\n signatureScope |= TransactionSignature.SIGHASH_NONE;\n }\n if (signOutputs === \"single\") {\n signatureScope |= TransactionSignature.SIGHASH_SINGLE;\n }\n if (anyoneCanPay) {\n signatureScope |= TransactionSignature.SIGHASH_ANYONECANPAY;\n }\n\n const input = tx.inputs[inputIndex];\n\n const otherInputs = tx.inputs.filter(\n (_, index) => index !== inputIndex\n );\n\n const sourceTXID = input.sourceTXID\n ? input.sourceTXID\n : input.sourceTransaction?.id(\"hex\");\n if (!sourceTXID) {\n throw stacklessError(\n \"The input sourceTXID or sourceTransaction is required for transaction signing.\"\n );\n }\n sourceSatoshis ||=\n input.sourceTransaction?.outputs[input.sourceOutputIndex].satoshis;\n if (!sourceSatoshis) {\n throw stacklessError(\n \"The sourceSatoshis or input sourceTransaction is required for transaction signing.\"\n );\n }\n lockingScript ||=\n input.sourceTransaction?.outputs[input.sourceOutputIndex]\n .lockingScript;\n if (!lockingScript) {\n throw stacklessError(\n \"The lockingScript or input sourceTransaction is required for transaction signing.\"\n );\n }\n\n const preimage = TransactionSignature.format({\n sourceTXID,\n sourceOutputIndex: input.sourceOutputIndex,\n sourceSatoshis,\n transactionVersion: tx.version,\n otherInputs,\n inputIndex,\n outputs: tx.outputs,\n inputSequence: input.sequence || 0xffffffff,\n subscript: lockingScript,\n lockTime: tx.lockTime,\n scope: signatureScope,\n });\n const rawSignature = userPrivateKey.sign(Hash.sha256(preimage));\n const sig = new TransactionSignature(\n rawSignature.r,\n rawSignature.s,\n signatureScope\n );\n const unlockScript = new UnlockingScript();\n unlockScript.writeBin(sig.toChecksigFormat());\n unlockScript.writeBin(\n userPrivateKey.toPublicKey().encode(true) as number[]\n );\n return unlockScript;\n },\n estimateLength: async () => {\n // public key (1+33) + signature (1+73) + approver signature (1+73)\n // Note: We add 1 to each element's length because of the associated OP_PUSH\n return 182;\n },\n };\n }\n\n /**\n * Creates a function that generates a P2PKH unlocking script along with its signature and length estimation.\n *\n * The returned object contains:\n * 1. `sign` - A function that, when invoked with a transaction and an input index,\n * produces an unlocking script suitable for a P2PKH locked output.\n * 2. `estimateLength` - A function that returns the estimated length of the unlocking script in bytes.\n *\n * @param {PrivateKey} approverPrivateKey - The private key used for signing the transaction.\n * @param {'all'|'none'|'single'} signOutputs - The signature scope for outputs.\n * @param {boolean} anyoneCanPay - Flag indicating if the signature allows for other inputs to be added later.\n * @param {number} sourceSatoshis - Optional. The amount being unlocked. Otherwise the input.sourceTransaction is required.\n * @param {Script} lockingScript - Optional. The lockinScript. Otherwise the input.sourceTransaction is required.\n * @returns {Object} - An object containing the `sign` and `estimateLength` functions.\n */\n unlock(\n approverPrivateKey: PrivateKey,\n userSigScript: Script,\n signOutputs: \"all\" | \"none\" | \"single\" = \"all\",\n anyoneCanPay = false,\n sourceSatoshis?: number,\n lockingScript?: Script\n ): {\n sign: (tx: Transaction, inputIndex: number) => Promise<UnlockingScript>;\n estimateLength: () => Promise<182>;\n } {\n return {\n sign: async (tx: Transaction, inputIndex: number) => {\n let signatureScope = TransactionSignature.SIGHASH_FORKID;\n if (signOutputs === \"all\") {\n signatureScope |= TransactionSignature.SIGHASH_ALL;\n }\n if (signOutputs === \"none\") {\n signatureScope |= TransactionSignature.SIGHASH_NONE;\n }\n if (signOutputs === \"single\") {\n signatureScope |= TransactionSignature.SIGHASH_SINGLE;\n }\n if (anyoneCanPay) {\n signatureScope |= TransactionSignature.SIGHASH_ANYONECANPAY;\n }\n\n const input = tx.inputs[inputIndex];\n\n const otherInputs = tx.inputs.filter(\n (_, index) => index !== inputIndex\n );\n\n const sourceTXID = input.sourceTXID\n ? input.sourceTXID\n : input.sourceTransaction?.id(\"hex\");\n if (!sourceTXID) {\n throw stacklessError(\n \"The input sourceTXID or sourceTransaction is required for transaction signing.\"\n );\n }\n sourceSatoshis ||=\n input.sourceTransaction?.outputs[input.sourceOutputIndex].satoshis;\n if (!sourceSatoshis) {\n throw stacklessError(\n \"The sourceSatoshis or input sourceTransaction is required for transaction signing.\"\n );\n }\n lockingScript ||=\n input.sourceTransaction?.outputs[input.sourceOutputIndex]\n .lockingScript;\n if (!lockingScript) {\n throw stacklessError(\n \"The lockingScript or input sourceTransaction is required for transaction signing.\"\n );\n }\n\n const preimage = TransactionSignature.format({\n sourceTXID,\n sourceOutputIndex: input.sourceOutputIndex,\n sourceSatoshis,\n transactionVersion: tx.version,\n otherInputs,\n inputIndex,\n outputs: tx.outputs,\n inputSequence: input.sequence || 0xffffffff,\n subscript: lockingScript,\n lockTime: tx.lockTime,\n scope: signatureScope,\n });\n const rawSignature = approverPrivateKey.sign(Hash.sha256(preimage));\n const sig = new TransactionSignature(\n rawSignature.r,\n rawSignature.s,\n signatureScope\n );\n const unlockScript = new UnlockingScript();\n unlockScript.writeBin(sig.toChecksigFormat());\n unlockScript.writeScript(userSigScript);\n return unlockScript;\n },\n estimateLength: async () => {\n // public key (1+33) + signature (1+73) + approver signature (1+73)\n // Note: We add 1 to each element's length because of the associated OP_PUSH\n return 182;\n },\n };\n }\n}\n","import { LockingScript } from '@bsv/sdk';\nimport { stacklessError } from './stacklessError';\n\n/**\n * MAP (Magic Attribute Protocol) metadata object with stringified values for writing to the blockchain\n * @typedef {Object} MAP\n * @property {string} app - Application identifier\n * @property {string} type - Metadata type\n * @property {string} [prop] - Optional. Additional metadata properties\n */\nexport type MAP = {\n app: string;\n type: string;\n [prop: string]: string;\n};\n\nexport type Inscription = {\n dataB64: string;\n contentType: string;\n};\n\n/**\n * Converts a string to its hexadecimal representation\n *\n * @param {string} utf8Str - The string to convert\n * @returns {string} The hexadecimal representation of the input string\n */\nconst toHex = (utf8Str: string): string => {\n return Buffer.from(utf8Str).toString('hex');\n};\n\nexport const MAP_PREFIX = '1PuQa7K62MiKCtssSLKy1kh56WWU7MtUR5';\n\nexport const applyInscription = (\n lockingScript: LockingScript,\n inscription?: Inscription,\n metaData?: MAP,\n withSeparator = false,\n) => {\n let ordAsm = '';\n // This can be omitted for reinscriptions that just update metadata\n if (inscription?.dataB64 !== undefined && inscription?.contentType !== undefined) {\n const ordHex = toHex('ord');\n const fsBuffer = Buffer.from(inscription.dataB64, 'base64');\n const fileHex = fsBuffer.toString('hex').trim();\n if (!fileHex) {\n throw stacklessError('Invalid file data');\n }\n const fileMediaType = toHex(inscription.contentType);\n if (!fileMediaType) {\n throw stacklessError('Invalid media type');\n }\n ordAsm = `OP_0 OP_IF ${ordHex} OP_1 ${fileMediaType} OP_0 ${fileHex} OP_ENDIF`;\n }\n\n let inscriptionAsm = `${\n ordAsm ? `${ordAsm} ${withSeparator ? 'OP_CODESEPARATOR ' : ''}` : ''\n }${lockingScript.toASM()}`;\n\n // MAP.app and MAP.type keys are required\n if (metaData && (!metaData.app || !metaData.type)) {\n throw stacklessError('MAP.app and MAP.type are required fields');\n }\n\n if (metaData?.app && metaData?.type) {\n const mapPrefixHex = toHex(MAP_PREFIX);\n const mapCmdValue = toHex('SET');\n inscriptionAsm = `${inscriptionAsm ? `${inscriptionAsm} ` : ''}OP_RETURN ${mapPrefixHex} ${mapCmdValue}`;\n\n for (const [key, value] of Object.entries(metaData)) {\n if (key !== 'cmd') {\n inscriptionAsm = `${inscriptionAsm} ${toHex(key)} ${toHex(value as string)}`;\n }\n }\n }\n\n return LockingScript.fromASM(inscriptionAsm);\n};\n","export const MIN_TRANSFER_AMOUNT = 0.00001;\nexport const MNEE_DECIMALS = 5;\n\n// PROD\nexport const MNEE_PROXY_API_URL = 'https://proxy-api.mnee.net';\nexport const PUBLIC_PROD_MNEE_API_TOKEN = '92982ec1c0975f31979da515d46bae9f';\nexport const PROD_TOKEN_ID = 'ae59f3b898ec61acbdb6cc7a245fabeded0c094bf046f35206a3aec60ef88127_0';\nexport const PROD_APPROVER = '020a177d6a5e6f3a8689acd2e313bd1cf0dcf5a243d1cc67b7218602aee9e04b2f';\nexport const PROD_MINT_ADDRESS = '1inHbiwj2jrEcZPiSYnfgJ8FmS1Bmk4Dh';\n\n// SANDBOX\nexport const SANDBOX_MNEE_API_URL = 'https://sandbox-proxy-api.mnee.net';\nexport const PUBLIC_SANDBOX_MNEE_API_TOKEN = '54f1fd1688ba66a58a67675b82feb93e';\nexport const SANDBOX_TOKEN_ID = '833a7720966a2a435db28d967385e8aa7284b6150ebb39482cc5228b73e1703f_0';\nexport const SANDBOX_APPROVER = '02bed35e894cc41cc9879b4002ad03d33533b615c1b476068c8dd6822a09f93f6c';\nexport const SANDBOX_MINT_ADDRESS = '1AZNdbFYBDFTAEgzZMfPzANxyNrpGJZAUY';\n","import { Hash, OP, Script, Transaction, Utils, PrivateKey } from '@bsv/sdk';\nimport {\n Inscription,\n MNEEConfig,\n MneeInscription,\n MneeSync,\n ParsedCosigner,\n SendMNEE,\n TransferMultiOptions,\n TxHistory,\n TxStatus,\n TxType,\n} from '../mnee.types';\nimport { stacklessError } from './stacklessError';\nimport { MIN_TRANSFER_AMOUNT } from '../constants';\n\nexport const parseInscription = (script: Script) => {\n let fromPos: number | undefined;\n for (let i = 0; i < script.chunks.length; i++) {\n const chunk = script.chunks[i];\n if (\n i >= 2 &&\n chunk.data?.length === 3 &&\n Utils.toUTF8(chunk.data) == 'ord' &&\n script.chunks[i - 1].op == OP.OP_IF &&\n script.chunks[i - 2].op == OP.OP_FALSE\n ) {\n fromPos = i + 1;\n }\n }\n if (fromPos === undefined) return;\n\n const insc = {\n file: { hash: '', size: 0, type: '' },\n fields: {},\n } as Inscription;\n\n for (let i = fromPos; i < script.chunks.length; i += 2) {\n const field = script.chunks[i];\n if (field.op == OP.OP_ENDIF) {\n break;\n }\n if (field.op > OP.OP_16) return;\n const value = script.chunks[i + 1];\n if (value.op > OP.OP_PUSHDATA4) return;\n\n if (field.data?.length) continue;\n\n let fieldNo = 0;\n if (field.op > OP.OP_PUSHDATA4 && field.op <= OP.OP_16) {\n fieldNo = field.op - 80;\n } else if (field.data?.length) {\n fieldNo = field.data[0];\n }\n switch (fieldNo) {\n case 0:\n insc.file!.size = value.data?.length || 0;\n if (!value.data?.length) break;\n insc.file!.hash = Utils.toBase64(Hash.sha256(value.data));\n insc.file!.content = value.data;\n break;\n case 1:\n insc.file!.type = Buffer.from(value.data || []).toString();\n break;\n }\n }\n\n return insc;\n};\n\nexport const parseCosignerScripts = (scripts: Script[]): ParsedCosigner[] => {\n return scripts\n .map((script: Script) => {\n const chunks = script.chunks;\n for (let i = 0; i <= chunks.length - 4; i++) {\n if (\n chunks.length > i + 6 &&\n chunks[0 + i].op === OP.OP_DUP &&\n chunks[1 + i].op === OP.OP_HASH160 &&\n chunks[2 + i].data?.length === 20 &&\n chunks[3 + i].op === OP.OP_EQUALVERIFY &&\n chunks[4 + i].op === OP.OP_CHECKSIGVERIFY &&\n chunks[5 + i].data?.length === 33 &&\n chunks[6 + i].op === OP.OP_CHECKSIG\n ) {\n return {\n cosigner: Utils.toHex(chunks[5 + i].data || []),\n address: Utils.toBase58Check(chunks[2 + i].data || [], [0]),\n };\n } else if (\n // P2PKH\n chunks[0 + i].op === OP.OP_DUP &&\n chunks[1 + i].op === OP.OP_HASH160 &&\n chunks[2 + i].data?.length === 20 &&\n chunks[3 + i].op === OP.OP_EQUALVERIFY &&\n chunks[4 + i].op === OP.OP_CHECKSIG\n ) {\n return {\n cosigner: '',\n address: Utils.toBase58Check(chunks[2 + i].data || [], [0]),\n };\n }\n }\n // Return undefined for scripts that don't match any pattern\n return undefined as any;\n })\n .filter((result): result is ParsedCosigner => result !== undefined);\n};\n\nexport const parseSyncToTxHistory = (sync: MneeSync, address: string, config: MNEEConfig): TxHistory | null => {\n const txType: TxType = sync.senders.includes(address) ? 'send' : 'receive';\n const txStatus: TxStatus = sync.height > 0 ? 'confirmed' : 'unconfirmed';\n\n if (!sync.rawtx) return null;\n\n const txArray = Utils.toArray(sync.rawtx, 'base64');\n const txHex = Utils.toHex(txArray);\n const tx = Transaction.fromHex(txHex);\n\n const outScripts = tx.outputs.map((output) => output.lockingScript);\n const mneeScripts = parseCosignerScripts(outScripts);\n const parsedOutScripts = outScripts.map(parseInscription);\n const mneeAddresses = mneeScripts.map((script) => script.address);\n\n const feeAddressIndex = mneeAddresses.indexOf(config.feeAddress);\n const sender = sync.senders[0]; // only one sender for now\n\n let fee = 0;\n const counterpartyAmounts = new Map<string, number>();\n\n parsedOutScripts.forEach((parsedScript, index) => {\n const content = parsedScript?.file?.content;\n if (!content) return;\n\n const inscriptionData = Utils.toUTF8(content);\n if (!inscriptionData) return;\n\n let inscriptionJson: MneeInscription;\n try {\n inscriptionJson = JSON.parse(inscriptionData);\n } catch (err) {\n console.error('Failed to parse inscription JSON:', err);\n return;\n }\n\n if (inscriptionJson.p !== 'bsv-20' || inscriptionJson.id !== config.tokenId) return;\n\n const inscriptionAmt = parseInt(inscriptionJson.amt, 10);\n if (Number.isNaN(inscriptionAmt)) return;\n\n if (feeAddressIndex === index && sender === address) {\n fee += inscriptionAmt;\n return;\n }\n\n const outAddr = mneeAddresses[index];\n const prevAmt = counterpartyAmounts.get(outAddr) || 0;\n counterpartyAmounts.set(outAddr, prevAmt + inscriptionAmt);\n });\n\n const amountSentToAddress = counterpartyAmounts.get(address) || 0;\n\n if (txType === 'send') {\n const senderAmt = counterpartyAmounts.get(sender) || 0;\n counterpartyAmounts.set(sender, senderAmt - amountSentToAddress);\n }\n\n let counterparties: { address: string; amount: number }[] = [];\n if (txType === 'receive') {\n counterparties = [{ address: sender, amount: amountSentToAddress }];\n } else {\n counterparties = Array.from(counterpartyAmounts.entries())\n .map(([addr, amt]) => ({ address: addr, amount: amt }))\n .filter((cp) => cp.address !== address && cp.address !== config.feeAddress && cp.amount > 0);\n }\n\n const totalCounterpartyAmount = counterparties.reduce((sum, cp) => sum + cp.amount, 0);\n\n return {\n txid: sync.txid,\n height: sync.height,\n type: txType,\n status: txStatus,\n amount: totalCounterpartyAmount,\n fee,\n score: sync.score,\n counterparties,\n };\n};\n\nexport const validateAddress = (address: string) => {\n try {\n const decoded = Utils.fromBase58Check(address);\n // 0x00 = mainnet P2PKH (addresses starting with '1')\n const validPrefixes = [0x00];\n const prefixByte = decoded.prefix[0];\n if (typeof prefixByte !== 'number' || !validPrefixes.includes(prefixByte)) {\n throw stacklessError(`Invalid address prefix: ${prefixByte}`);\n }\n // Ensure the payload is 20 bytes (160 bits) for P2PKH/P2SH\n if (decoded.data.length !== 20) {\n throw stacklessError(`Invalid address payload length: ${decoded.data.length}`);\n }\n return true;\n } catch (error) {\n return false;\n }\n};\n\nexport const validateWIF = (wif: string): { isValid: boolean; error?: string; privateKey?: PrivateKey } => {\n try {\n const privateKey = PrivateKey.fromWif(wif);\n return { isValid: true, privateKey };\n } catch (wifError) {\n if (wifError instanceof Error) {\n const errorMsg = wifError.message.toLowerCase();\n if (errorMsg.includes('invalid base58 character')) {\n return { isValid: false, error: 'Invalid WIF key: contains invalid characters' };\n } else if (errorMsg.includes('invalid checksum')) {\n return { isValid: false, error: 'Invalid WIF key: checksum verification failed' };\n } else if (errorMsg.includes('expected base58 string')) {\n return { isValid: false, error: 'Invalid WIF key: must be a valid base58 encoded string' };\n }\n }\n return { isValid: false, error: 'Invalid WIF key provided' };\n }\n};\n\nexport const validateTransferMultiOptions = (options: TransferMultiOptions): { isValid: boolean; error?: string } => {\n for (const recipient of options.recipients) {\n if (!recipient.address || !recipient.amount) {\n return {\n isValid: false,\n error: `Invalid recipient: ${JSON.stringify(recipient)}. Missing required fields: address, amount`,\n };\n }\n\n if (typeof recipient.amount !== 'number' || isNaN(recipient.amount) || !isFinite(recipient.amount)) {\n return { isValid: false, error: `Invalid amount for ${recipient.address}: amount must be a valid number` };\n }\n\n if (recipient.amount < MIN_TRANSFER_AMOUNT) {\n return {\n isValid: false,\n error: `Invalid amount for ${recipient.address}: minimum transfer amount is ${MIN_TRANSFER_AMOUNT} MNEE`,\n };\n }\n\n if (!validateAddress(recipient.address)) {\n return { isValid: false, error: `Invalid recipient address: ${recipient.address}` };\n }\n }\n\n for (const input of options.inputs) {\n if (!input.txid || typeof input.vout !== 'number' || !input.wif) {\n return {\n isValid: false,\n error: `Invalid input: ${JSON.stringify(input)}. Missing required fields: txid, vout, wif`,\n };\n }\n const wifValidation = validateWIF(input.wif);\n if (!wifValidation.isValid) {\n return { isValid: false, error: `Invalid WIF key: ${input.wif} for input ${input.txid}:${input.vout}` };\n }\n const privateKey = wifValidation.privateKey!;\n const address = privateKey.toAddress();\n if (!validateAddress(address)) {\n return { isValid: false, error: `Invalid input address: ${address}` };\n }\n }\n\n if (options.changeAddress && Array.isArray(options.changeAddress)) {\n for (const change of options.changeAddress) {\n if (change.amount < MIN_TRANSFER_AMOUNT) {\n return {\n isValid: false,\n error: `Invalid amount for ${change.address}: minimum transfer amount is ${MIN_TRANSFER_AMOUNT} MNEE`,\n };\n }\n if (!validateAddress(change.address)) {\n return { isValid: false, error: `Invalid change address: ${change.address}` };\n }\n }\n }\n\n return { isValid: true };\n};\n\nexport const validateTransferOptions = (\n options: SendMNEE[],\n wif: string,\n): { isValid: boolean; totalAmount?: number; privateKey?: PrivateKey; error?: string } => {\n const { isValid, error, privateKey } = validateWIF(wif);\n if (options.length === 0) {\n return { isValid: false, error: 'Empty transfer options provided. Please provide at least one recipient.' };\n }\n\n if (!isValid) {\n return { isValid: false, error: error || 'Invalid WIF key provided' };\n }\n\n // This should never happen, but just in case\n if (!privateKey) {\n return { isValid: false, error: 'Private key not found' };\n }\n\n let totalAmount = 0;\n for (const req of options) {\n if (!validateAddress(req.address)) {\n return { isValid: false, error: `Invalid recipient address: ${req.address}` };\n }\n if (typeof req.amount !== 'number' || isNaN(req.amount) || !isFinite(req.amount)) {\n return { isValid: false, error: `Invalid amount for ${req.address}: amount must be a valid number` };\n }\n if (req.amount < MIN_TRANSFER_AMOUNT) {\n return {\n isValid: false,\n error: `Invalid amount for ${req.address}: minimum transfer amount is ${MIN_TRANSFER_AMOUNT} MNEE`,\n };\n }\n totalAmount += req.amount;\n }\n\n if (totalAmount <= 0) return { isValid: false, error: 'Invalid amount: total must be greater than 0' };\n\n return { isValid: true, totalAmount, privateKey };\n};\n\nexport const isValidHex = (hex: string) => {\n try {\n Transaction.fromHex(hex);\n return true;\n } catch (error) {\n return false;\n }\n};\n","export interface NetworkErrorInfo {\n code: string;\n message: string;\n hostname?: string;\n originalError?: any;\n}\n\nexport class NetworkError extends Error {\n public code: string;\n public hostname?: string;\n public originalError?: any;\n\n constructor(info: NetworkErrorInfo) {\n super(info.message);\n this.name = 'NetworkError';\n this.code = info.code;\n this.hostname = info.hostname;\n this.originalError = info.originalError;\n }\n}\n\nexport function isNetworkError(error: any): boolean {\n if (!error) return false;\n \n // Check for common network error codes\n const networkErrorCodes = [\n 'ENOTFOUND',\n 'ECONNREFUSED',\n 'ETIMEDOUT',\n 'ECONNRESET',\n 'ENETUNREACH',\n 'EHOSTUNREACH',\n 'EPIPE',\n 'ECONNABORTED'\n ];\n \n // Check direct error code\n if (networkErrorCodes.includes(error.code)) {\n return true;\n }\n \n // Check nested cause\n if (error.cause && networkErrorCodes.includes(error.cause?.code)) {\n return true;\n }\n \n // Check for fetch failures\n if (error.message?.includes('fetch failed') || error.message?.includes('getaddrinfo')) {\n return true;\n }\n \n return false;\n}\n\nexport function parseNetworkError(error: any): NetworkError {\n // Handle fetch errors with nested causes\n if (error.cause && error.cause.code) {\n const cause = error.cause;\n let message = 'Network connection failed';\n \n switch (cause.code) {\n case 'ENOTFOUND':\n message = 'Unable to connect to MNEE network. Please check your internet connection.';\n break;\n case 'ECONNREFUSED':\n message = 'Connection refused by MNEE server. The service may be temporarily unavailable.';\n break;\n case 'ETIMEDOUT':\n message = 'Request timed out. Please check your internet connection and try again.';\n break;\n case 'ECONNRESET':\n message = 'Connection was reset. Please try again.';\n break;\n case 'ENETUNREACH':\n case 'EHOSTUNREACH':\n message = 'Network unreachable. Please check your internet connection.';\n break;\n default:\n message = `Network error: ${cause.code}. Please check your connection and try again.`;\n }\n \n return new NetworkError({\n code: cause.code,\n message,\n hostname: cause.hostname,\n originalError: error\n });\n }\n \n // Handle direct network errors\n if (error.code) {\n let message = 'Network error occurred';\n \n switch (error.code) {\n case 'ENOTFOUND':\n message = 'Unable to connect to MNEE network. Please check your internet connection.';\n break;\n case 'ECONNREFUSED':\n message = 'Connection refused by MNEE server. The service may be temporarily unavailable.';\n break;\n case 'ETIMEDOUT':\n message = 'Request timed out. Please check your internet connection and try again.';\n break;\n default:\n message = `Network error: ${error.code}. Please check your connection and try again.`;\n }\n \n return new NetworkError({\n code: error.code,\n message,\n hostname: error.hostname,\n originalError: error\n });\n }\n \n // Generic network error\n return new NetworkError({\n code: 'NETWORK_ERROR',\n message: 'Network error occurred. Please check your internet connection and try again.',\n originalError: error\n });\n}\n\nexport function logNetworkError(error: any, operation: string): string | undefined {\n if (isNetworkError(error)) {\n const networkError = parseNetworkError(error);\n console.error(`Network error during ${operation}: ${networkError.message}`);\n return networkError.message;\n } else {\n console.error(`Failed to ${operation}:`, error);\n return undefined;\n }\n}","/**\n * Batch operations for MNEE SDK\n * Provides a clean API for batch processing with automatic chunking, rate limiting, and error recovery\n */\n\nimport { MNEEService } from './mneeService.js';\nimport {\n MNEEUtxo,\n MNEEBalance,\n TxHistoryResponse,\n AddressHistoryParams,\n ParseTxResponse,\n ParseTxExtendedResponse,\n ParseOptions,\n} from './mnee.types.js';\nimport { stacklessError } from './utils/stacklessError.js';\n\nexport interface BatchOptions {\n /** Maximum items per API call (default: 20) */\n chunkSize?: number;\n /** API requests per second limit (default: 3). If your API key has a higher limit, set this accordingly */\n requestsPerSecond?: number;\n /** Continue processing if an error occurs (default: false) */\n continueOnError?: boolean;\n /** Maximum retries per chunk (default: 3) */\n maxRetries?: number;\n /** Retry delay in milliseconds (default: 1000) */\n retryDelay?: number;\n /** Progress callback */\n onProgress?: (completed: number, total: number, errors: number) => void;\n}\n\nexport interface BatchError {\n items: string[];\n error: {\n message: string;\n code?: string;\n };\n retryCount: number;\n}\n\nexport interface BatchResult<T> {\n results: T[];\n errors: BatchError[];\n totalProcessed: number;\n totalErrors: number;\n}\n\nexport interface BatchUtxoResult {\n address: string;\n utxos: MNEEUtxo[];\n}\n\nexport interface BatchParseTxResult {\n txid: string;\n parsed: ParseTxResponse | ParseTxExtendedResponse;\n}\n\n/**\n * Batch operations class for MNEE SDK\n * @example\n * const batch = mnee.batch();\n * const result = await batch.getBalances(addresses, { onProgress: ... });\n */\nexport class Batch {\n constructor(private service: MNEEService) {}\n\n /**\n * Get UTXOs for multiple addresses\n * @example\n * const result = await mnee.batch().getUtxos(addresses, {\n * onProgress: (completed, total, errors) => {\n * console.log(`Progress: ${completed}/${total}, Errors: ${errors}`);\n * }\n * });\n */\n async getUtxos(addresses: string[], options: BatchOptions = {}): Promise<BatchResult<BatchUtxoResult>> {\n // Validate input is an array\n if (!Array.isArray(addresses)) {\n throw stacklessError('Input must be an array of addresses');\n }\n \n // Track individual errors within chunks\n const individualErrors: BatchError[] = [];\n\n const modifiedProcessor = async (chunk: string[]) => {\n // First validate addresses\n const validAddresses: string[] = [];\n const invalidAddresses: string[] = [];\n \n for (const address of chunk) {\n if (!address || typeof address !== 'string' || address.trim() === '') {\n invalidAddresses.push(address);\n individualErrors.push({\n items: [address],\n error: { message: 'Invalid address: empty or not a string' },\n retryCount: 0,\n });\n } else {\n // Basic Bitcoin address validation (starts with 1, 3, or bc1)\n const isValid = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^bc1[a-z0-9]{39,59}$/.test(address);\n if (isValid) {\n validAddresses.push(address);\n } else {\n invalidAddresses.push(address);\n individualErrors.push({\n items: [address],\n error: { message: `Invalid address format: ${address}` },\n retryCount: 0,\n });\n }\n }\n }\n\n // If continueOnError is false and we have invalid addresses, throw\n if (!options.continueOnError && invalidAddresses.length > 0) {\n throw new Error(individualErrors[0].error.message);\n }\n\n // Process only valid addresses\n if (validAddresses.length === 0) {\n return [];\n }\n\n const utxos = await this.service.getUtxos(validAddresses);\n \n // Return results for all addresses in chunk (valid ones get UTXOs, invalid get empty)\n return chunk.map((address) => ({\n address,\n utxos: validAddresses.includes(address) \n ? utxos.filter((utxo) => utxo.owners.includes(address))\n : [],\n }));\n };\n\n const batchResult = await this.processBatch(\n addresses,\n modifiedProcessor,\n options,\n );\n\n // Merge individual errors with any chunk-level errors\n return {\n ...batchResult,\n errors: [...batchResult.errors, ...individualErrors],\n totalErrors: batchResult.errors.length + individualErrors.length,\n };\n }\n\n /**\n * Get balances for multiple addresses\n * @example\n * const result = await mnee.batch().getBalances(addresses);\n * const totalBalance = result.results.reduce((sum, b) => sum + b.decimalAmount, 0);\n */\n async getBalances(addresses: string[], options: BatchOptions = {}): Promise<BatchResult<MNEEBalance>> {\n // Validate input is an array\n if (!Array.isArray(addresses)) {\n throw stacklessError('Input must be an array of addresses');\n }\n \n return this.processBatch(addresses, async (chunk) => this.service.getBalances(chunk), options);\n }\n\n /**\n * Get transaction histories for multiple addresses\n * @example\n * const params = addresses.map(addr => ({ address: addr, limit: 100 }));\n * const result = await mnee.batch().getTxHistories(params);\n */\n async getTxHistories(\n params: AddressHistoryParams[],\n options: BatchOptions = {},\n ): Promise<BatchResult<TxHistoryResponse>> {\n // Validate input is an array\n if (!Array.isArray(params)) {\n throw stacklessError('Input must be an array of address history parameters');\n }\n \n return this.processBatch(\n params,\n async (chunk) => this.service.getRecentTxHistories(chunk),\n options,\n (param) => param.address,\n );\n }\n\n /**\n * Parse multiple transactions\n * @example\n * const result = await mnee.batch().parseTx(txids, {\n * parseOptions: { includeRaw: true }\n * });\n */\n async parseTx(\n txids: string[],\n options: BatchOptions & { parseOptions?: ParseOptions } = {},\n ): Promise<BatchResult<BatchParseTxResult>> {\n // Validate input is an array\n if (!Array.isArray(txids)) {\n throw stacklessError('Input must be an array of transaction IDs');\n }\n \n const { parseOptions, ...batchOptions } = options;\n\n // Track individual errors within chunks\n const individualErrors: BatchError[] = [];\n\n const modifiedProcessor = async (chunk: string[]) => {\n const results = await Promise.allSettled(\n chunk.map(async (txid) => {\n // Validate txid first\n if (!txid || typeof txid !== 'string' || txid.trim() === '') {\n throw new Error('Invalid transaction ID: empty or not a string');\n }\n \n const hexRegex = /^[a-fA-F0-9]{64}$/;\n if (!hexRegex.test(txid)) {\n throw new Error(`Invalid transaction ID format: ${txid}`);\n }\n\n return {\n txid,\n parsed: await this.service.parseTx(txid, parseOptions),\n };\n }),\n );\n\n const successfulResults: BatchParseTxResult[] = [];\n \n results.forEach((result, index) => {\n const txid = chunk[index];\n if (result.status === 'fulfilled') {\n successfulResults.push(result.value);\n } else {\n // Track individual errors\n const errorMessage = result.reason instanceof Error \n ? result.reason.message \n : String(result.reason);\n individualErrors.push({\n items: [txid],\n error: { message: errorMessage },\n retryCount: batchOptions.maxRetries || 3,\n });\n }\n });\n\n // If continueOnError is false and we have errors, throw the first one\n if (!batchOptions.continueOnError && individualErrors.length > 0) {\n throw new Error(individualErrors[0].error.message);\n }\n\n return successfulResults;\n };\n\n const batchResult = await this.processBatch(\n txids,\n modifiedProcessor,\n batchOptions, // Respect user's continueOnError preference\n );\n\n // Merge individual errors with any chunk-level errors\n return {\n ...batchResult,\n errors: [...batchResult.errors, ...individualErrors],\n totalErrors: batchResult.errors.length + individualErrors.length,\n };\n }\n\n /**\n * Generic batch processor\n */\n private async processBatch<T, R>(\n items: T[],\n processor: (chunk: T[]) => Promise<R[]>,\n options: BatchOptions,\n getItemId?: (item: T) => string,\n ): Promise<BatchResult<R>> {\n const {\n chunkSize = 20,\n continueOnError = false,\n onProgress,\n maxRetries = 3,\n retryDelay = 1000,\n requestsPerSecond = 3,\n } = options;\n \n const validChunkSize = chunkSize > 0 ? chunkSize : 20;\n const validRequestsPerSecond = requestsPerSecond > 0 ? requestsPerSecond : 3;\n\n // Create rate limiter based on requests per second\n // For requestsPerSecond < 1, we need to ensure maxConcurrent is at least 1\n // but still respect the intended delay between requests\n const minDelay = Math.ceil(1000 / validRequestsPerSecond);\n const maxConcurrent = Math.max(1, Math.floor(validRequestsPerSecond));\n const rateLimiter = new RateLimiter(maxConcurrent, minDelay);\n\n if (items.length === 0) {\n return { results: [], errors: [], totalProcessed: 0, totalErrors: 0 };\n }\n\n const results: R[] = [];\n const errors: BatchError[] = [];\n let processed = 0;\n\n const chunks = this.chunkArray(items, validChunkSize);\n const totalChunks = chunks.length;\n\n // Process all chunks - rate limiter handles concurrency and timing\n const chunkPromises = chunks.map(async (chunk) => {\n try {\n const chunkResults = await this.processWithRetry(() => processor(chunk), maxRetries, retryDelay, rateLimiter);\n\n results.push(...chunkResults);\n processed++;\n\n if (onProgress) {\n onProgress(processed, totalChunks, errors.length);\n }\n\n return chunkResults;\n } catch (error) {\n if (!continueOnError) {\n throw error;\n }\n\n // When continueOnError is true and chunk processing fails,\n // try to process items individually to salvage partial results\n const partialResults: R[] = [];\n const failedItems: { item: T; error: Error }[] = [];\n\n for (const item of chunk) {\n try {\n // Process single item by wrapping in array\n const singleResult = await this.processWithRetry(\n () => processor([item]),\n maxRetries,\n retryDelay,\n rateLimiter,\n );\n if (singleResult.length > 0) {\n partialResults.push(...singleResult);\n }\n } catch (itemError) {\n const error = itemError instanceof Error \n ? itemError\n : new Error(String(itemError));\n failedItems.push({ item, error });\n }\n }\n\n // Add partial results\n results.push(...partialResults);\n\n // Record errors for failed items\n if (failedItems.length > 0) {\n const itemIds = failedItems.map(({ item }) => (getItemId ? getItemId(item) : (item as unknown as string)));\n const firstError = failedItems[0].error;\n errors.push({\n items: itemIds,\n error: {\n message: firstError.message,\n code: (firstError as any).code,\n },\n retryCount: maxRetries,\n });\n }\n\n processed++;\n if (onProgress) {\n onProgress(processed, totalChunks, errors.length);\n }\n\n return partialResults;\n }\n });\n\n // Wait for all chunks to complete\n await Promise.all(chunkPromises);\n\n return {\n results,\n errors,\n totalProcessed: processed,\n totalErrors: errors.length,\n };\n }\n\n /**\n * Process with retry logic\n */\n private async processWithRetry<T>(\n func: () => Promise<T>,\n maxRetries: number,\n retryDelay: number,\n rateLimiter: RateLimiter,\n ): Promise<T> {\n let lastError: Error | undefined;\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n return await rateLimiter.execute(func);\n } catch (error) {\n lastError = error as Error;\n if (attempt < maxRetries - 1) {\n await this.delay(retryDelay * (attempt + 1));\n }\n }\n }\n\n throw lastError || new Error('Max retries exceeded');\n }\n\n /**\n * Split array into chunks\n */\n private chunkArray<T>(array: T[], chunkSize: number): T[][] {\n const chunks: T[][] = [];\n const size = Math.max(1, chunkSize); // Ensure chunk size is at least 1\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size));\n }\n return chunks;\n }\n\n /**\n * Delay execution\n */\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n\n/**\n * Rate limiter for API calls\n */\nexport class RateLimiter {\n private queue: Array<() => void> = [];\n private running = 0;\n\n constructor(private maxConcurrent: number, private minDelay: number) {}\n\n async execute<T>(fn: () => Promise<T>): Promise<T> {\n await this.waitForSlot();\n\n try {\n this.running++;\n const start = Date.now();\n const result = await fn();\n\n // Ensure minimum delay between calls\n const elapsed = Date.now() - start;\n if (elapsed < this.minDelay) {\n await this.delay(this.minDelay - elapsed);\n }\n\n return result;\n } finally {\n this.running--;\n this.processQueue();\n }\n }\n\n private waitForSlot(): Promise<void> {\n if (this.running < this.maxConcurrent) {\n return Promise.resolve();\n }\n\n return new Promise((resolve) => {\n this.queue.push(resolve);\n });\n }\n\n private processQueue(): void {\n if (this.queue.length > 0 && this.running < this.maxConcurrent) {\n const next = this.queue.shift();\n if (next) next();\n }\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n","import {\n Hash,\n P2PKH,\n PrivateKey,\n PublicKey,\n Script,\n Transaction,\n TransactionSignature,\n UnlockingScript,\n Utils,\n} from '@bsv/sdk';\nimport {\n Environment,\n GetSignatures,\n MNEEBalance,\n MNEEConfig,\n MneeInscription,\n SdkConfig,\n MneeSync,\n MNEEUtxo,\n ParseTxResponse,\n ParseTxExtendedResponse,\n ParseOptions,\n SendMNEE,\n TransferMultiOptions,\n SignatureRequest,\n SignatureResponse,\n TxHistory,\n TxHistoryResponse,\n TxOperation,\n AddressHistoryParams,\n TxAddressAmount,\n TransferResponse,\n TransferStatus,\n TxInputResponse,\n ProcessedInput,\n TxOutputResponse,\n ProcessedOutput,\n TransferOptions,\n BalanceResponse,\n UnsignedTransactionResult,\n MultisigBuildOptions,\n} from './mnee.types.js';\nimport CosignTemplate from './mneeCosignTemplate.js';\nimport { applyInscription } from './utils/applyInscription.js';\nimport {\n isValidHex,\n parseCosignerScripts,\n parseInscription,\n parseSyncToTxHistory,\n validateAddress,\n validateTransferMultiOptions,\n validateTransferOptions,\n} from './utils/helper.js';\nimport { isNetworkError, logNetworkError } from './utils/networkError.js';\nimport { stacklessError } from './utils/stacklessError.js';\nimport {\n MNEE_PROXY_API_URL,\n SANDBOX_MNEE_API_URL,\n PROD_TOKEN_ID,\n PROD_MINT_ADDRESS,\n PROD_APPROVER,\n PUBLIC_PROD_MNEE_API_TOKEN,\n PUBLIC_SANDBOX_MNEE_API_TOKEN,\n SANDBOX_TOKEN_ID,\n SANDBOX_MINT_ADDRESS,\n SANDBOX_APPROVER,\n MNEE_DECIMALS,\n} from './constants.js';\nimport { RateLimiter } from './batch.js';\n\nexport class MNEEService {\n private mneeApiKey: string;\n private mneeConfig: MNEEConfig | undefined;\n private mneeApi: string;\n\n constructor(config: SdkConfig) {\n if (config.environment !== 'production' && config.environment !== 'sandbox') {\n throw stacklessError('Invalid environment. Must be either \"production\" or \"sandbox\"');\n }\n\n const isProd = config.environment === 'production';\n if (config?.apiKey === '') {\n throw stacklessError('MNEE API key cannot be an empty string');\n }\n if (config?.apiKey) {\n this.mneeApiKey = config.apiKey;\n } else {\n this.mneeApiKey = isProd ? PUBLIC_PROD_MNEE_API_TOKEN : PUBLIC_SANDBOX_MNEE_API_TOKEN;\n }\n this.mneeApi = isProd ? MNEE_PROXY_API_URL : SANDBOX_MNEE_API_URL;\n this.getCosignerConfig().catch(() => {});\n }\n\n public async getCosignerConfig(): Promise<MNEEConfig> {\n try {\n const response = await fetch(`${this.mneeApi}/v1/config?auth_token=${this.mneeApiKey}`, { method: 'GET' });\n\n if (response.status === 401 || response.status === 403) {\n throw stacklessError('Invalid API key');\n }\n\n if (!response.ok) throw stacklessError(`HTTP error! status: ${response.status}`);\n const data: MNEEConfig = await response.json();\n this.mneeConfig = data;\n return data;\n } catch (error) {\n if (isNetworkError(error)) {\n logNetworkError(error, 'fetch config');\n }\n throw error;\n }\n }\n\n public toAtomicAmount(amount: number): number {\n return Math.round(amount * 10 ** MNEE_DECIMALS);\n }\n\n public fromAtomicAmount(amount: number): number {\n return amount / 10 ** MNEE_DECIMALS;\n }\n\n public async createInscriptionOutput(recipient: string, amount: number, config: MNEEConfig) {\n const inscriptionData = {\n p: 'bsv-20',\n op: 'transfer',\n id: config.tokenId,\n amt: amount.toString(),\n };\n return {\n lockingScript: applyInscription(new CosignTemplate().lock(recipient, PublicKey.fromString(config.approver)), {\n dataB64: Buffer.from(JSON.stringify(inscriptionData)).toString('base64'),\n contentType: 'application/bsv-20',\n }),\n satoshis: 1,\n };\n }\n\n public async getUtxos(\n address: string | string[],\n page?: number,\n size?: number,\n order?: 'asc' | 'desc',\n ): Promise<MNEEUtxo[]> {\n try {\n if (!address) {\n throw stacklessError('Address is required');\n }\n\n // Handle single address\n if (typeof address === 'string') {\n if (!validateAddress(address)) {\n throw stacklessError(`Invalid Bitcoin address: ${address}`);\n }\n const arrayAddress = [address];\n const response = await fetch(\n `${this.mneeApi}/v2/utxos?auth_token=${this.mneeApiKey}${page !== undefined ? `&page=${page}` : ''}${\n size !== undefined ? `&size=${size}` : ''\n }${order ? `&order=${order}` : ''}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(arrayAddress),\n },\n );\n if (response.status === 401 || response.status === 403) {\n throw stacklessError('Invalid API key');\n }\n if (!response.ok) throw stacklessError(`HTTP error! status: ${response.status}`);\n const data: MNEEUtxo[] = await response.json();\n const ops = ['transfer', 'deploy+mint'];\n return data.filter((utxo) =>\n ops.includes(utxo.data.bsv21.op.toLowerCase() as 'transfer' | 'burn' | 'deploy+mint'),\n );\n }\n\n // Handle array of addresses - filter out invalid ones\n if (Array.isArray(address)) {\n const validAddresses = address.filter((addr) => typeof addr === 'string' && validateAddress(addr));\n\n if (validAddresses.length === 0) {\n throw stacklessError('No valid Bitcoin addresses provided');\n }\n\n // Log warning about invalid addresses\n const invalidAddresses = address.filter((addr