commit f2210e631aad8a05d0203fcbad327ec6a026614c Author: James Zuccon Date: Fri Aug 16 22:06:01 2024 +1000 Initial commit Signed-off-by: James Zuccon diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9d08a1a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b7ba514 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +/dist +/node_modules +.eslintrc.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..1bd1c24 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,13 @@ +{ + "extends": [ "@generalprotocols/eslint-config/typescript" ], + "parserOptions": + { + "project": "./tsconfig.json", + "ecmaVersion": "2018", + "sourceType": "module" + }, + "env": + { + "jest": true + } +} diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..df30ba9 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,65 @@ +module.exports = { + // https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy + // This option interrupts the configuration hierarchy at this file + // Remove this if you have an higher level ESLint config file (it usually happens into a monorepos) + root: true, + + // https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser + // Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working + // `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted + parserOptions: { + parser: require.resolve('@typescript-eslint/parser'), + extraFileExtensions: [ '.vue' ] + }, + + env: { + browser: true, + es2021: true, + node: true, + }, + + // Rules order is important, please avoid shuffling them + extends: [ + // Base ESLint recommended rules + // 'eslint:recommended', + + // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage + // ESLint typescript rules + 'plugin:@typescript-eslint/recommended', + + // https://github.com/prettier/eslint-config-prettier#installation + // usage with Prettier, provided by 'eslint-config-prettier'. + 'prettier' + ], + + plugins: [ + // required to apply rules which need type information + '@typescript-eslint', + + // https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674 + // Prettier has not been included as plugin to avoid performance impact + // add it as an extension for your IDE + + ], + + // add your custom rules here + rules: { + + 'prefer-promise-reject-errors': 'off', + + quotes: ['warn', 'single', { avoidEscape: true }], + + // this rule, if on, would require explicit return type on the `render` function + '@typescript-eslint/explicit-function-return-type': 'off', + + // in plain CommonJS modules, you can't use `import foo = require('foo')` to pass this rule, so it has to be disabled + '@typescript-eslint/no-var-requires': 'off', + + // The core 'no-unused-vars' rules (in the eslint:recommended ruleset) + // does not work with type definitions + 'no-unused-vars': 'off', + + // allow debugger during development only + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5058b34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.nyc_output/ +dist/ +coverage/ +public/ +.vscode +yarn.lock +yarn-error.log +unit-tests.xml +e2e-tests.xml diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..32bd84d --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +# pnpm-related options +shamefully-hoist=true +strict-peer-dependencies=false diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..650cb88 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "semi": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3870433 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# XO Stack + +TODO diff --git a/jsdoc.json b/jsdoc.json new file mode 100644 index 0000000..75e0452 --- /dev/null +++ b/jsdoc.json @@ -0,0 +1,35 @@ +{ + "tags": { + "allowUnknownTags": false + }, + "source": { + "include": ["lib/", "./README.md"], + "includePattern": "\\.(jsx|js|ts|tsx)$", + "excludePattern": "(node_modules/|docs)" + }, + "plugins": ["node_modules/better-docs/typescript"], + "opts": { + "template": "node_modules/docdash", + "encoding": "utf8", + "destination": "public/", + "recurse": true, + "verbose": false + }, + "templates": { + "cleverLinks": false, + "monospaceLinks": false, + "default": { + "outputSourceFiles": true, + "includeDate": true, + "useLongnameInNav": true + } + }, + "docdash": { + "collapse": false, + "static": true, + "sort": true, + "search": false, + "private": false, + "typedefs": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8cbd9c0 --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "@xo/stack", + "version": "0.0.1", + "description": "XO Stack", + "type": "module", + "main": "dist/index.cjs.js", + "module": "dist/index.mjs.js", + "typings": "dist/index.d.ts", + "sideEffects": false, + "scripts": { + "build": "npm run clean && npm run compile && npm run rollup && npm run post-processing", + "compile": "tsc", + "clean": "del dist", + "post-processing": "del ./dist/module", + "docs": "jsdoc -c jsdoc.json", + "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", + "lint": "eslint .", + "rollup": "rollup -c", + "spellcheck": "cspell '**'", + "test": "ava" + }, + "repository": { + "type": "git", + "url": "git+https://gitlab.com/GeneralProtocols/xo/stack" + }, + "keywords": [ + "bitcoin cash" + ], + "files": [ + "dist" + ], + "author": "GeneralProtocols", + "license": "MIT", + "bugs": { + "url": "https://gitlab.com/GeneralProtocols/xo/stack" + }, + "homepage": "https://gitlab.com/GeneralProtocols/xo/stack", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^6.3.0", + "@typescript-eslint/parser": "^6.3.0", + "eslint": "^8.10.0", + "eslint-config-prettier": "^8.1.0", + "prettier": "^2.5.1", + "rollup": "^3.29.2", + "rollup-plugin-dts": "^6.0.2", + "tsc-watch": "^6.0.4", + "typescript": "^5.1.3" + }, + "ava": { + "extensions": { + "ts": "module" + }, + "nodeArguments": [ + "--loader=ts-node/esm" + ] + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..f05a5b4 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,30 @@ +// Import package.json to reduce name repetition. +// eslint-disable-next-line import/extensions +import pkg from './package.json'; +import dts from 'rollup-plugin-dts'; + +export default [ + // Build main module in both ES and CJS + { + // Specify entry point for building + input: 'dist/module/lib/index.js', + + // Omit all dependencies and peer dependencies from the final bundle + external: [ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + ], + + // Produce outputs for ES and CJS + output: [ + { file: pkg.module, format: 'es' }, + { file: pkg.main, format: 'cjs' }, + ], + }, + // Build type definitions for the library + { + input: 'dist/module/lib/index.d.ts', + output: [{ file: pkg.typings, format: 'es' }], + plugins: [dts()], + }, +]; diff --git a/src/primitives/address.ts b/src/primitives/address.ts new file mode 100644 index 0000000..c21a262 --- /dev/null +++ b/src/primitives/address.ts @@ -0,0 +1,422 @@ +import { + Base58AddressFormatVersion, + addressContentsToLockingBytecode, + lockingBytecodeToAddressContents, + isHex, + binToHex, + hexToBin, + decodeBase58Address, + decodeCashAddress, + disassembleBytecodeBCH, + encodeBase58AddressFormat, + encodeCashAddress, + sha256, + ripemd160, +} from '@bitauth/libauth'; + +import type { KnownAddressTypeContents } from '@bitauth/libauth'; + +export type AddressType = 'P2PK' | 'P2PKH' | 'P2SH20' | 'P2SH32'; + +/** + * Address Entity. + */ +export class Address { + public readonly addressContents: KnownAddressTypeContents; + + constructor(addressContents: KnownAddressTypeContents) { + this.addressContents = addressContents; + } + + /** + * Decode a CashAddr into an Address Entity. + * + * @param address {string} The CashAddr to decode. + * + * @throws {Error} If address cannot be decoded. + * + * @returns {Address} Instance of Address. + * + * @example + * const address = Address.fromCashAddr('bitcoincash:qpeqv9k9j3l7fepp9du24f9pr5jdu7c2mvn4wwldav') + */ + public static fromCashAddr(cashAddr: string): Address { + // CashAddr + const decodeResult = decodeCashAddress(cashAddr); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Define the encode type in advance. + let encodeType: AddressType; + + // Determine the encoding to use based on the Decoding Result's Type. + switch (decodeResult.type) { + case 'p2pkh': + encodeType = 'P2PKH'; + break; + case 'p2sh': + encodeType = decodeResult.payload.length === 32 ? 'P2SH32' : 'P2SH20'; + break; + default: + throw new Error(`Unsupported address type: ${decodeResult.type}`); + } + + // Return new instance of address. + return new Address({ payload: decodeResult.payload, type: encodeType }); + } + + /** + * Decode a Legacy Address into an Address Entity. + * + * @param address {string} The Legacy (Base58) Address to decode. + * + * @throws {Error} If address cannot be decoded. + * + * @returns {Address} Instance of Address. + */ + public static fromLegacy(legacyAddress: string): Address { + // Decode the Legacy (Base58) Address. + const decodeResult = decodeBase58Address(legacyAddress); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Define the encode type in advance. + let encodeType: AddressType; + + // Determine the encoding to use based on the Decoding Result's Type. + switch (decodeResult.version) { + case Base58AddressFormatVersion.p2pkh: + case Base58AddressFormatVersion.p2pkhTestnet: + encodeType = 'P2PKH'; + break; + case Base58AddressFormatVersion.p2sh20: + case Base58AddressFormatVersion.p2sh20Testnet: + encodeType = 'P2SH20'; + break; + default: + throw new Error(`Unsupported address type: ${decodeResult.version}`); + } + + return new Address({ payload: decodeResult.payload, type: encodeType }); + } + + /** + * Decode a Cash Address or Legacy Address. + * + * @param address {string} The address to attempt to decode. + * + * @throws {Error} If address cannot be decoded. + * + * @returns {Address} Instance of Address. + */ + public static fromCashAddrOrLegacy(address: string): Address { + // Attempt to decode as a Cash Address. + try { + return Address.fromCashAddr(address); + } catch (error) { + // And if that fails, attempt to decode as a Base58 Address. + return Address.fromLegacy(address); + } + } + + /** + * Instantiate an address from Contract Bytecode. + * + * @param contractBytes {Uint8Array} The contract bytecode + * + * @throws {Error} If address cannot be instantiated from Contract Bytecode. + * + * @returns {Address} Instance of Address. + */ + public static fromRedeemScript( + contractBytes: Uint8Array, + type: 'P2SH20' | 'P2SH32' = 'P2SH32' + ): Address { + // Do an initial SHA256 on the Contract Bytes. + let payload = sha256.hash(contractBytes); + + if (type === 'P2SH20') { + // RIPEMD160 the SHA256 hash. + payload = ripemd160.hash(payload); + } else if (type === 'P2SH32') { + // SHA256 Hash the SHA256 hash a seconr time. + payload = sha256.hash(payload); + } + + // Return a new instance of address. + return new Address({ payload, type }); + } + + /** + * Instantiate an address from Contract Bytecode as a hex string. + * + * @param contractBytes {Uint8Array} The contract bytecode + * + * @throws {Error} If address cannot be instantiated from Contract Bytecode. + * + * @returns {Address} Instance of Address. + */ + public static fromRedeemScriptHex( + hex: string, + type: 'P2SH20' | 'P2SH32' = 'P2SH32' + ): Address { + if (!isHex(hex)) { + throw new Error('Invalid hex'); + } + + const contractBytecode = hexToBin(hex); + + // Return a new instance of address. + return Address.fromRedeemScript(contractBytecode, type); + } + + /** + * Attempt to instantiate Address from Locking Bytecode. + * + * @param lockScript {Uint8Array} The lockscript bytes. + * + * @throws {Error} If lock script cannot be decoded into an address. + * + * @returns {Address} Instance of Address. + */ + public static fromLockscriptBytes(lockScript: Uint8Array): Address { + const addressContents = lockingBytecodeToAddressContents(lockScript); + + // If the type is not recognized, throw an error... + if (addressContents.type === 'unknown') { + throw new Error(`Invalid type (${addressContents.type})`); + } + + return new Address(addressContents); + } + + /** + * Attempt to instantiate Address from Locking Bytecode as a hex string. + * + * @param hex {Uint8Array} The lockscript bytes as hex. + * + * @throws {Error} If lock script cannot be decoded into an address. + * + * @returns {Address} Instance of Address. + */ + public static fromLockscriptHex(hex: string): Address { + if (!isHex(hex)) { + throw new Error('Invalid hex'); + } + + const lockScriptBytes = hexToBin(hex); + + return Address.fromLockscriptBytes(lockScriptBytes); + } + + /** + * Instantiate an address from a Ripemd160 hash. + * + * @param hash160 {Uint8Array} The hash to use. + * @param type {AddressType} The type of the address to instantiate (e.g. P2PKH). + * + * @throws {Error} If hash160 is not 20 bytes. + * + * @returns {Address} Instance of Address. + */ + public static fromHash160( + hash160: Uint8Array, + type: AddressType = 'P2PKH' + ): Address { + // If the Hash160 bytes is not 20 in length, it is invalid... + if (hash160.length !== 20) { + throw new Error( + `Invalid hash160 length: Expected 20 (Received ${hash160.length})` + ); + } + + return new Address({ payload: hash160, type }); + } + + /** + * Instantiate an address from a Ripemd160 hash (as hex). + * + * @param hash160Hex {string} The hash to use (as hex). + * @param type {AddressType} The type of the address to instantiate (e.g. P2PKH). + * + * @throws {Error} If hash160 is not 20 bytes. + * + * @returns {Address} Instance of Address. + */ + public static fromHash160Hex( + hash160Hex: string, + type: AddressType = 'P2PKH' + ): Address { + if (!isHex(hash160Hex)) { + throw new Error('Invalid hex'); + } + + const hash160Bytes = hexToBin(hash160Hex); + + return Address.fromHash160(hash160Bytes, type); + } + + public static isValid(address: string): boolean { + try { + Address.fromCashAddrOrLegacy(address); + } catch (error) { + return false; + } + + return true; + } + + public static isValidLegacy(address: string): boolean { + try { + Address.fromLegacy(address); + } catch (error) { + return false; + } + + return true; + } + + public static isValidCashAddr(address: string): boolean { + try { + Address.fromCashAddr(address); + } catch (error) { + return false; + } + + return true; + } + + /** + * Gets the type of address (P2PKH, P2SH). + * + * @returns {AddressType} The type of Address. + */ + public type(): AddressType { + return this.addressContents.type; + } + + /** + * Gets this address' raw Hash160 (Ripemd160) hash as a Uint8Array. + * + * @returns {Uint8Array} The Hash160 of this address. + */ + public toHash160Bytes(): Uint8Array { + return this.addressContents.payload; + } + + /** + * Gets this address' raw Hash160 (Ripemd16) hash as a hex string. + * + * @returns {Uint8Array} The Hash160 of this address. + */ + public toHash160Hex(): string { + return binToHex(this.addressContents.payload); + } + + /** + * Converts this address to CashAddr format. + * + * @param networkPrefix {'bitcoincash' | 'bchtest' | 'bchreg'} The Cash Address Network prefix. + * + * @throws {Error} If address cannot be encoded as a CashAddr. + * + * @returns {string} The address in CashAddr format. + */ + public toCashAddr( + networkPrefix: 'bitcoincash' | 'bchtest' | 'bchreg' = 'bitcoincash', + supportTokens = false + ): string { + // Define the encode type in advance. + let encodeType: 'p2pkh' | 'p2sh' | 'p2pkhWithTokens' | 'p2shWithTokens'; + + // Determine the encoding to use based on the Address Content's Type. + switch (this.addressContents.type) { + case 'P2PKH': + encodeType = supportTokens ? 'p2pkhWithTokens' : 'p2pkh'; + break; + case 'P2SH20': + encodeType = supportTokens ? 'p2shWithTokens' : 'p2sh'; + break; + case 'P2SH32': + encodeType = supportTokens ? 'p2shWithTokens' : 'p2sh'; + break; + default: + throw new Error( + `Unsupported address type: ${this.addressContents.type}` + ); + } + + // NOTE: This function will throw if the payload is invalid. + return encodeCashAddress({ + prefix: networkPrefix, + type: encodeType, + payload: this.addressContents.payload, + }).address; + } + + /** + * Converts this address to Legacy (Base58) Address format. + * + * @throws {Error} If address cannot be encoded as a Legacy Address (Base58). + * + * @returns {string} The address in Legacy Address format. + */ + public toLegacy(): string { + // Define the encode type in advance. + let encodeType: Base58AddressFormatVersion; + + // Determine the encoding to use based on the Address Content's Type. + switch (this.addressContents.type) { + case 'P2PKH': + encodeType = Base58AddressFormatVersion.p2pkh; + break; + case 'P2SH20': + encodeType = Base58AddressFormatVersion.p2sh20; + break; + default: + throw new Error( + `Unsupported address type: ${this.addressContents.type}` + ); + } + + // Encode the address in Base58 format. + return encodeBase58AddressFormat(encodeType, this.addressContents.payload); + } + + /** + * Get the corresponding lockscript for this address as a Uint8Array. + * + * @returns {Uint8Array} The lockscript as a Uint8Array. + */ + public toLockscriptBytes(): Uint8Array { + return addressContentsToLockingBytecode(this.addressContents); + } + + /** + * Get the corresponding lockscript for this address as a hex string. + * + * @returns {Uint8Array} The lockscript as a hex string. + */ + public toLockscriptHex(): string { + return binToHex(this.toLockscriptBytes()); + } + + /** + * Get the corresponding lockscript for this address as ASM. + * + * @returns {Uint8Array} The lockscript as ASN. + */ + public toLockscriptAsm(): string { + const lockscriptBytes = this.toLockscriptBytes(); + + const lockscriptAsm = disassembleBytecodeBCH(lockscriptBytes); + + return lockscriptAsm; + } +} diff --git a/src/primitives/hd-private-node.ts b/src/primitives/hd-private-node.ts new file mode 100644 index 0000000..2ceb581 --- /dev/null +++ b/src/primitives/hd-private-node.ts @@ -0,0 +1,178 @@ +import { HDPublicNode } from './hd-public-node.js'; +import { PrivateKey } from './private-key.js'; + +import { + encodeHdPrivateKey, + decodeHdPrivateKey, + deriveHdPath, + deriveHdPublicNode, + deriveHdPrivateNodeFromSeed, + generateRandomBytes, +} from '@bitauth/libauth'; + +import type { + DecodedHdKey, + HdKeyNetwork, + HdPrivateNodeValid, +} from '@bitauth/libauth'; + +/** + * Hierarchically Deterministic Private Node Entity. + */ +export class HDPrivateNode { + // The HD Private Node. + public readonly node: HdPrivateNodeValid; + + /** + * Construct a new HD Private Node. + * + * @param node {HdPrivateNodeValid} The HD Private Node. + */ + constructor(node: HdPrivateNodeValid) { + this.node = node; + } + + /** + * Creates a HD Private Node from the given XPriv Key. + * + * @param xpriv {string} The XPriv Key. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns {HDPrivateNode} The created HD Private Node. + */ + public static fromXPriv(xpriv: string): HDPrivateNode { + // Attempt to decode the XPriv Key. + const decodeResult = decodeHdPrivateKey(xpriv); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new HD Private Node from the given XPriv. + return new HDPrivateNode(decodeResult.node); + } + + /** + * Creates a HD Private Node from a raw seed. + * + * @param seed {Uint8Array} The raw seed bytes. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns {HDPrivateNode} The created HD Private Node. + */ + public static fromSeed(seed: Uint8Array): HDPrivateNode { + // If the seed is not 32 bytes, throw an error. + /*if (seed.length !== 32) { + throw new Error( + `Failed to derive Private Node from seed: Seed must be 32 bytes (${seed.length} given)` + ); + }*/ + + // Attempt to derive HD Private Node from the given seed. + const deriveResult = deriveHdPrivateNodeFromSeed(seed); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return a new HD Private Node from the given seed. + return new HDPrivateNode(deriveResult); + } + + /** + * Generates a random HD Private Node. + * + * @throws {Error} If HD Private Node cannot be created. + * + * @returns {HDPrivateNode} The created HD Private Node. + */ + public static generateRandom(): HDPrivateNode { + // Attempt to derive HD Private Node from randomly generated bytes. + const deriveResult = deriveHdPrivateNodeFromSeed(generateRandomBytes(32)); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return the new HD Private Node from the random bytes. + return new HDPrivateNode(deriveResult); + } + + /** + * Returns the XPriv string for this node. + * + * @returns {string} The XPriv string for this node. + */ + public toString(): string { + // Return the Mainnet XPriv. + return this.toXPriv(); + } + + /** + * Gets the Private Key entity for this node. + * + * @returns {PrivateKey} The Private Key Entity for this node. + */ + public privateKey(): PrivateKey { + // Return a Private Key Entity from the private key of our node. + return new PrivateKey(this.node.privateKey); + } + + /** + * Derives a HD Private Node from the given BIP32 path. + * + * @param path {string} The BIP32 Path (e.g. m/44'/145'/0'/0/1). + * + * @throws {Error} If HD Private Node cannot be derived. + * + * @returns {HDPrivateNode} The derived HD Private Node + */ + public derivePath(path: string): HDPrivateNode { + // Attempt to derive the given path. + const derivePathResult = deriveHdPath(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Private Node Entity derived from the given path. + return new HDPrivateNode(derivePathResult); + } + + /** + * Derives a HD Public Node from this Private Node. + * + * @returns {HDPublicNode} The derived HD Public Node + */ + public deriveHDPublicNode(): HDPublicNode { + // Derive a HD Public Node from this HD Private Node. + const publicNode = deriveHdPublicNode(this.node); + + // Return a new HD Public Node entity from our derived Public Node. + return new HDPublicNode(publicNode); + } + + /** + * Converts this node to an XPriv Key for export. + * + * @param network {HdKeyNetwork} The network to encode for. + * + * @returns {string} The XPriv Key. + */ + public toXPriv(network: HdKeyNetwork = 'mainnet'): string { + // Create our node info structure. + const nodeInfo: DecodedHdKey = { + network: network, + node: this.node, + }; + + // Encode the XPriv from our node info. + return encodeHdPrivateKey(nodeInfo).hdPrivateKey; + } +} diff --git a/src/primitives/hd-public-node.ts b/src/primitives/hd-public-node.ts new file mode 100644 index 0000000..c35db3d --- /dev/null +++ b/src/primitives/hd-public-node.ts @@ -0,0 +1,112 @@ +import { PublicKey } from './public-key.js'; + +import { + encodeHdPublicKey, + decodeHdPublicKey, + deriveHdPath, +} from '@bitauth/libauth'; + +import type { + DecodedHdKey, + HdKeyNetwork, + HdPublicNodeValid, +} from '@bitauth/libauth'; + +/** + * Hierarchically Deterministic Public Node Entity. + */ +export class HDPublicNode { + // The HD Public Node. + public readonly node: HdPublicNodeValid; + + /** + * Construct a new HD Public Node. + * + * @param node {HdPublicNode} The HD Public Node. + */ + constructor(node: HdPublicNodeValid) { + this.node = node; + } + + /** + * Creates a HD Public Node from the given XPriv Key. + * + * @param xpub {string} The XPriv Key. + * + * @throws {Error} If HD Public Node cannot be created. + * + * @returns {HDPrivateNode} The created HD Public Node. + */ + public static fromXPub(xpub: string): HDPublicNode { + // Attempt to decode the XPub Key. + const decodeResult = decodeHdPublicKey(xpub); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new HD Public Node from the given XPub. + return new HDPublicNode(decodeResult.node); + } + + /** + * Returns the XPub string for this node. + * + * @returns {string} The XPub string for this node. + */ + public toString(): string { + // Return the Mainnet XPub. + return this.toXPub(); + } + + /** + * Gets the Public Key Entity for this node. + * + * @returns {PublicKey} The Public Key Entity for this node. + */ + public publicKey(): PublicKey { + // Return a Public Key Entity from the public key of our node. + return new PublicKey(this.node.publicKey); + } + + /** + * Derives a HD Public Node from the given BIP32 path. + * + * @param path {string} The BIP32 Path (e.g. m/44'/145'/0'/0/1). + * + * @throws {Error} If HD Public Node cannot be derived. + * + * @returns {HDPrivateNode} The derived HD Public Node + */ + public derivePath(path: string): HDPublicNode { + // Attempt to derive the given path. + const derivePathResult = deriveHdPath(this.node, path); + + // If a string is returned, this indicates an error... + if (typeof derivePathResult === 'string') { + throw new Error(derivePathResult); + } + + // Return a new HD Public Node Entity derived from the given path. + return new HDPublicNode(derivePathResult); + } + + /** + * Converts this node to an XPub Key for export. + * + * @param network {HdKeyNetwork} The network to encode for. + * + * @returns {string} The XPub Key. + */ + public toXPub(network: HdKeyNetwork = 'mainnet'): string { + // Create our node info structure. + const nodeInfo = { + network: network, + node: this.node, + } as DecodedHdKey; + + // Encode the XPub from our node info. + return encodeHdPublicKey(nodeInfo).hdPublicKey; + } +} diff --git a/src/primitives/index.ts b/src/primitives/index.ts new file mode 100644 index 0000000..0e06b75 --- /dev/null +++ b/src/primitives/index.ts @@ -0,0 +1,7 @@ +export * from './address.js'; +export * from './hd-private-node.js'; +export * from './hd-public-node.js'; +export * from './private-key.js'; +export * from './public-key.js'; +export * from './satoshis.js'; +export * from './transaction.js'; diff --git a/src/primitives/private-key.ts b/src/primitives/private-key.ts new file mode 100644 index 0000000..4a9cf5c --- /dev/null +++ b/src/primitives/private-key.ts @@ -0,0 +1,310 @@ +import { PublicKey } from './public-key.js'; + +import { + binToHex, + decodePrivateKeyWif, + encodePrivateKeyWif, + generatePrivateKey, + hexToBin, + isHex, + binToUtf8, + utf8ToBin, + bigIntToCompactUint, + secp256k1, + sha256, +} from '@bitauth/libauth'; + +import type { + RecoverableSignature, + WalletImportFormatType, +} from '@bitauth/libauth'; + +/** + * Private Key entity. + */ +export class PrivateKey { + // The raw bytes representing the Private Key. + public readonly bytes: Uint8Array; + + /** + * Construct a new Private Key. + * + * @throws {Error} If Private Key cannot be constructed. + * + * @param bytes {Uint8Array} The raw bytes (32) for the Private Key. + */ + constructor(bytes: Uint8Array) { + // Ensure that the Private Key is valid. + if (!secp256k1.validatePrivateKey(bytes)) { + throw new Error( + `Private Key is invalid: Must be >= 0x01 and <= 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364140.` + ); + } + + this.bytes = bytes; + } + + /** + * Create a Private Key Entity from the given bytes (as hexadecimal string). + * + * @param privateKeyHex {string} Raw Private Key bytes encoded as hexadecimal string. + * + * @throws {Error} If Private Key could not be created. + * + * @returns {PrivateKey} The created Private Key Entity. + */ + public static fromHex(privateKeyHex: string): PrivateKey { + // Ensure that the given string is hex. + if (!isHex(privateKeyHex)) { + throw new Error( + `The given string (${privateKeyHex}) is not valid hexadecimal.` + ); + } + + // Convert the hex to binary (Uint8Array). + const privateKeyBin = hexToBin(privateKeyHex); + + // Create a Private Key Entity from the binary. + return new PrivateKey(privateKeyBin); + } + + /** + * Create a Private Key from a given WIF (Note that the network information from the WIF will be lost). + * + * @throws {Error} If Private Key cannot be created. + * + * @returns {PrivateKey} The Private Key + */ + public static fromWIF( + wif: string, + removePrefixes = ['bitcoincash:', 'wif:'] + ): PrivateKey { + // Remove the prefixes from the WIF (if they exist). + const wifWithoutPrefixes = removePrefixes.reduce((result, prefix) => { + return result.startsWith(prefix) ? result.slice(prefix.length) : result; + }, wif); + + // Attempt to decode the WIF. + const decodeResult = decodePrivateKeyWif(wifWithoutPrefixes); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new Private Key entity. + return new PrivateKey(decodeResult.privateKey); + } + + /** + * Generate a random Private Key. + * + * @returns {PrivateKey} The randomly generated Private Key + */ + public static generateRandom(): PrivateKey { + const bytes = generatePrivateKey(); + + return new PrivateKey(bytes); + } + + /** + * Derive the public key from this private key. + * + * @throws {Error} If Public Key cannot be derived. + * + * @returns {PublicKey} The derived Public Key. + */ + public derivePublicKey(): PublicKey { + // Attempt to derive the public key. + const deriveResult = secp256k1.derivePublicKeyCompressed(this.bytes); + + // If a string is returned, this indicates an error... + if (typeof deriveResult === 'string') { + throw new Error(deriveResult); + } + + // Return the derived Public Key. + return new PublicKey(deriveResult); + } + + /** + * Sign a message hash with this Private Key using a Recoverable Compact Signature. + * + * @param messageHash {Uint8Array} The hash of the message. + * + * @throws {Error} If message hash cannot be signed. + * + * @returns {Uint8Array} The generated signature. + */ + public signMessageHashRecoverableCompact( + messageHash: Uint8Array + ): RecoverableSignature { + // Attempt to sign the message. + const signResult = secp256k1.signMessageHashRecoverableCompact( + this.bytes, + messageHash + ); + + // If a string is returned, this indicates an error... + if (typeof signResult === 'string') { + throw new Error(signResult); + } + + // Return the signature. + return signResult; + } + + /** + * Sign a message hash with this Private Key using a Compact Signature. + * + * @param messageHash {Uint8Array} The hash of the message. + * + * @throws {Error} If message hash cannot be signed. + * + * @returns {Uint8Array} The generated signature. + */ + public signMessageHashCompact(messageHash: Uint8Array): Uint8Array { + // Attempt to sign the message. + const signResult = secp256k1.signMessageHashCompact( + this.bytes, + messageHash + ); + + // If a string is returned, this indicates an error... + if (typeof signResult === 'string') { + throw new Error(signResult); + } + + // Return the signature. + return signResult; + } + + /** + * Sign a message hash with this Private Key using a Schnorr Signature. + * + * @param messageHash {Uint8Array} The hash of the message. + * + * @throws {Error} If message hash cannot be signed. + * + * @returns {Uint8Array} The generated signature. + */ + public signMessageHashSchnorr(messageHash: Uint8Array): Uint8Array { + // Attempt to sign the message. + const signResult = secp256k1.signMessageHashSchnorr( + this.bytes, + messageHash + ); + + // If a string is returned, this indicates an error... + if (typeof signResult === 'string') { + throw new Error(signResult); + } + + // Return the signature. + return signResult; + } + + /** + * Sign a message hash with this Private Key using a DER Signature. + * + * @param messageHash {Uint8Array} The hash of the message. + * + * @throws {Error} If message hash cannot be signed. + * + * @returns {Uint8Array} The generated signature. + */ + public signMessageHashDER(messageHash: Uint8Array): Uint8Array { + const signResult = secp256k1.signMessageHashDER(this.bytes, messageHash); + + // If a string is returned, this indicates an error... + if (typeof signResult === 'string') { + throw new Error(signResult); + } + + // Return the signature. + return signResult; + } + + /** + * Sign a message hash with this Private Key using an Electron Cash Signature. + * + * This applies the Bitcoin and Electron Magic Bytes. + * + * NOTE: Electron can verify this message successfully, but generates them differently internally. + * + * @param messageHash {Uint8Array} The hash of the message. + * + * @throws {Error} If message hash cannot be signed. + * + * @returns {Uint8Array} The generated signature. + */ + public signMessageElectron(message: string): Uint8Array { + // Compile the message. + const magicBytes = utf8ToBin('\x18Bitcoin Signed Message:\n'); + const messageBytes = utf8ToBin(message); + const messageLengthBytes = bigIntToCompactUint(BigInt(messageBytes.length)); // hexToBin(messageBytes.length.toString(16)); // + const messageFinal = new Uint8Array([ + ...magicBytes, + ...messageLengthBytes, + ...messageBytes, + ]); + + // Hash the message. + // NOTE: That we hash256 (2 x sha256) the message. + const messageHash = sha256.hash(sha256.hash(messageFinal)); + + // Use Recoverable Compact Signature. + const signature = this.signMessageHashRecoverableCompact(messageHash); + + // Apply Electon encoding. + const electronEncoding = new Uint8Array([ + ...[31 + signature.recoveryId], + ...signature.signature, + ]); + + // Return the signature. + return electronEncoding; + } + + /** + * Convert the raw bytes of this Private Key to hexadecimal encoding. + * + * @returns {string} The raw bytes of the Private Key as a hexadecimal string. + */ + public toHex(): string { + // Return the hexadecimal encoded bytes of this Private Key. + return binToHex(this.bytes); + } + + /** + * Convert this Private Key to a raw 32 byte representation. + * + * @returns {Uint8Array} The raw Private Key as a Uint8Array. + */ + public toBytes(): Uint8Array { + // Return the raw bytes for this Private Key. + return this.bytes; + } + + /** + * Convert this Private Key to a WIF format. + * + * @param type {WalletImportFormatType} The type of WIF to create. + * + * @returns {string} The Private Key in WIF format. + */ + public toWif(type: WalletImportFormatType = 'mainnet'): string { + // Return the encoded Private Key WIF. + return encodePrivateKeyWif(this.bytes, type); + } + + /** + * Returns the Private Key as a mainnet WIF. + * + * @returns {string} The Private Key as a mainnet WIF. + */ + public toString(): string { + // Return the encoded Mainnet Private Key WIF. + return this.toWif(); + } +} diff --git a/src/primitives/public-key.ts b/src/primitives/public-key.ts new file mode 100644 index 0000000..5e14a70 --- /dev/null +++ b/src/primitives/public-key.ts @@ -0,0 +1,188 @@ +import { Address } from './address.js'; + +import { + binToHex, + hexToBin, + isHex, + secp256k1, + sha256, + ripemd160, +} from '@bitauth/libauth'; + +/** + * Public Key Entity. + */ +export class PublicKey { + // The raw bytes representing the Public Key. + public readonly bytes: Uint8Array; + + /** + * Construct a new Public Key. + * + * @param bytes {Uint8Array} The raw bytes (33) for the Public Key. + */ + constructor(bytes: Uint8Array) { + // Ensure that exactly 33 Bytes are given. + if (!secp256k1.validatePublicKey(bytes)) { + throw new Error(`Invalid Public Key.`); + } + + this.bytes = bytes; + } + + /** + * Create a Public Key entity from the given hexadecimal string. + * + * @param publicKeyHex {string} Hexadecimal encoding of the Public Key bytes. + * + * @throws {Error} If Public Key cannot be created. + * + * @returns {PublicKey} The created Public Key entity. + */ + public static fromHex(publicKeyHex: string): PublicKey { + // Ensure that the given string is hex. + if (!isHex(publicKeyHex)) { + throw new Error( + `The given string (${publicKeyHex}) is not valid hexadecimal.` + ); + } + + // Convert the hex to binary (Uint8Array). + const publicKey = hexToBin(publicKeyHex); + + // Create a Public Key Entity from the binary. + return new PublicKey(publicKey); + } + + /** + * Derive the Hash160 for this Public Key. + * + * @returns {Uint8Array} The Hash160 of the Public Key. + */ + public deriveHash160(): Uint8Array { + // SHA256 Hash the public key. + const sha256Hash = sha256.hash(this.bytes); + + // RIPEMD160 the SHA256 hash. + const ripemd160Hash = ripemd160.hash(sha256Hash); + + // And then return it. + return ripemd160Hash; + } + + /** + * Derive the Hash160 as Hex for this Public Key. + * + * @returns {string} The Hash160 of the Public Key as hex. + */ + public deriveHash160Hex(): string { + const hash160Bytes = this.deriveHash160(); + + return binToHex(hash160Bytes); + } + + /** + * Derive the Address for this Public Key. + * + * @returns {Address} The Address for this Public Key. + */ + public deriveAddress(): Address { + // Get the RIPEMD160 Hash of the Public Key. + const ripemd160Hash = this.deriveHash160(); + + // Return an instance of address + return Address.fromHash160(ripemd160Hash, 'P2PKH'); + } + + /** + * Verify that a compact message hash was signed with the corresponding Private Key. + * + * @param messageHash {Uint8Array} The hash of the message. + * @param signature {Uint8Array) The signature of the message. + * + * @returns {boolean} True if signature and message hash verifies, false otherwise.. + */ + public verifyMessageHashCompact( + messageHash: Uint8Array, + signature: Uint8Array + ): boolean { + const validity = secp256k1.verifySignatureCompact( + signature, + this.bytes, + messageHash + ); + + return validity; + } + + /** + * Verify that a DER message hash was signed with the corresponding Private Key. + * + * @param messageHash {Uint8Array} The hash of the message. + * @param signature {Uint8Array) The signature of the message. + * + * @returns {boolean} True if signature and message hash verifies, false otherwise.. + */ + public verifyMessageHashDER( + messageHash: Uint8Array, + signature: Uint8Array + ): boolean { + const validity = secp256k1.verifySignatureDER( + signature, + this.bytes, + messageHash + ); + + return validity; + } + + /** + * Verify that a Schnorr message hash was signed with the corresponding Private Key. + * + * @param messageHash {Uint8Array} The hash of the message. + * @param signature {Uint8Array) The signature of the message. + * + * @returns {boolean} True if signature and message hash verifies, false otherwise.. + */ + public verifyMessageHashSchnorr( + messageHash: Uint8Array, + signature: Uint8Array + ): boolean { + const validity = secp256k1.verifySignatureSchnorr( + signature, + this.bytes, + messageHash + ); + + return validity; + } + + /** + * Encode the raw bytes of this Public Key to a hexadecimal representation. + * + * @returns {string} The raw bytes Public Key encoded as Hexadecimal. + */ + public toHex(): string { + return binToHex(this.bytes); + } + + /** + * Get the raw bytes of this Public Key. + * + * @returns {Uint8Array} The raw bytes of the Public Key. + */ + public toBytes(): Uint8Array { + // Return the raw bytes for this Public Key. + return this.bytes; + } + + /** + * Return the hexadecimal representation of the raw bytes of the Public Key. + * + * @returns {string} The raw bytes of the Public Key as hexadecimal. + */ + public toString(): string { + // Return the raw Public Key byes encoded as a hexadecimal string. + return this.toHex(); + } +} diff --git a/src/primitives/satoshis.ts b/src/primitives/satoshis.ts new file mode 100644 index 0000000..e1832ca --- /dev/null +++ b/src/primitives/satoshis.ts @@ -0,0 +1,166 @@ +/** + * Satoshis Primitive + */ +export class Satoshis { + public readonly satoshis: bigint; + + /** + * Construct a new instance of Satoshis. + * + * This is the same as using fromSatoshis(). + * + * @param satoshis {number} The satoshi amount. + */ + constructor(satoshis: bigint | number) { + // Set the satoshi amount. + this.satoshis = BigInt(satoshis); + } + + /** + * Instantiates from a Satoshi Amount. + * + * @param satoshis {number} The Satoshi amount. + * + * @returns {Satoshis} New instance of Satoshis. + */ + public static fromSats(satoshis: bigint | number): Satoshis { + return new Satoshis(satoshis); + } + + /** + * Instantiates from a Satoshi Amount. + * + * @param satoshis {number} The Satoshi amount. + * + * @deprecated Use fromSats() instead. + * + * @returns {Satoshis} New instance of Satoshis. + */ + public static fromSatoshis(satoshis: bigint | number): Satoshis { + return new Satoshis(satoshis); + } + + /** + * Instantiates from a Bits Amount (100 satoshis = 1 bit). + * + * @param bits {number} The Bits amount. + * + * @returns {Satoshis} New instance of Satoshis. + */ + public static fromBits(bits: number): Satoshis { + const satoshis = Satoshis.roundToDigits(bits * 100, 0); + + return new Satoshis(satoshis); + } + + /** + * Instantiates from a mBCH Amount (100,000 satoshis = 1 mBCH). + * + * @param mBCH {number} The mBCH amount. + * + * @returns {Satoshis} New instance of Satoshis. + */ + public static fromMBCH(mBCH: number): Satoshis { + const satoshis = Satoshis.roundToDigits(mBCH * 100_000, 0); + + return new Satoshis(satoshis); + } + + /** + * Instantiates from a BCH Amount (100,000,000 satoshis = 1 BCH). + * + * @param bch {number} The BCH amount. + * + * @returns {Satoshis} New instance of Satoshis. + */ + public static fromBCH(bch: number): Satoshis { + const satoshis = Satoshis.roundToDigits(bch * 100_000_000, 0); + + return new Satoshis(satoshis); + } + + /** + * Rounds a number to the given number of digits. + * + * @param numberToRound {number} The number to round. + * @param digits {number} The number of digits to round to. + * + * @returns {Number} The rounded number. + */ + public static roundToDigits(numberToRound: number, digits: number): number { + // Set the options of the Number Format object. + const options = { + style: 'decimal', + minimumFractionDigits: digits, + maximumFractionDigits: digits, + useGrouping: false, + numberingSystem: 'latn', + }; + + // Create an instance of number format using above options. + // NOTE: We force the locale to en-GB so that the number is formatted correctly (e.g. with a decimal, not a comma). + const numberFormat = new Intl.NumberFormat('en-GB', options); + + // Format the number. + const formattedAmount = numberFormat.format(numberToRound); + + // Return the formatted number. + return Number(formattedAmount); + } + + /** + * Converts the amount to Satoshis (as a BigInt). + * + * @returns {bigint} The satoshi amount. + */ + public toBigInt(): bigint { + return this.satoshis; + } + + /** + * Converts the amount to Satoshis. + * + * @returns {number} The satoshi amount. + */ + public toSats(): number { + return Number(this.satoshis); + } + + /** + * Converts the amount to Satoshis. + * + * @deprecated Use toSats() instead. + * + * @returns {number} The satoshi amount. + */ + public toSatoshis(): number { + return Number(this.satoshis); + } + + /** + * Converts the amount to Bits (100 satoshis = 1 bit). + * + * @returns {number} The bit amount. + */ + public toBits(): number { + return Satoshis.roundToDigits(Number(this.satoshis) / 100, 2); + } + + /** + * Converts the amount to mBCH (100,000 satoshis = 1 mBCH). + * + * @returns {number} The mBCH amount. + */ + public toMBCH(): number { + return Satoshis.roundToDigits(Number(this.satoshis) / 100_000, 5); + } + + /** + * Converts the amount to BCH (100,000,000 satoshis = 1 BCH). + * + * @returns {number} The BCH amount. + */ + public toBCH(): number { + return Satoshis.roundToDigits(Number(this.satoshis) / 100_000_000, 8); + } +} diff --git a/src/primitives/transaction.ts b/src/primitives/transaction.ts new file mode 100644 index 0000000..1f03cba --- /dev/null +++ b/src/primitives/transaction.ts @@ -0,0 +1,87 @@ +import { + binToHex, + hexToBin, + encodeTransaction, + decodeTransaction, + isHex, +} from '@bitauth/libauth'; +import type { TransactionBCH } from '@bitauth/libauth'; + +/** + * Transaction Entity. + */ +export class Transaction { + constructor(public transaction: TransactionBCH) {} + + /** + * Create a Transaction Entity from the given byte data. + * + * @param bytes {Uint8Array} The raw byte data of the transaction. + * + * @throws {Error} If transaction data cannot be decoded. + * + * @returns {Transaction} The created Transaction Entity. + */ + public static fromBytes(bytes: Uint8Array): Transaction { + // Attempt to decode the raw transaction bytes. + const decodeResult = decodeTransaction(bytes); + + // If a string is returned, this indicates an error... + if (typeof decodeResult === 'string') { + throw new Error(decodeResult); + } + + // Return a new instance of the Transaction Entity created from the decode result. + return new Transaction(decodeResult); + } + + /** + * Create a Transaction Entity from the given byte data encoded as hexadecimal string. + * + * @param hex {string} The raw byte data (encoded as hexadecimal string) of the transaction. + * + * @throws {Error} If transaction cannot be decoded. + * + * @returns {Transaction} The created Transaction Entity. + */ + public static fromHex(hex: string): Transaction { + // Ensure that the given string is hex. + if (!isHex(hex)) { + throw new Error('The given string is not valid hexadecimal.'); + } + + // Convert our hex to binary. + const bytes = hexToBin(hex); + + // Return the transaction constructed from our bytes. + return Transaction.fromBytes(bytes); + } + + public toBytes(): Uint8Array { + const bytes = encodeTransaction(this.transaction); + + return bytes; + } + + public toHex(): string { + const bytes = this.toBytes(); + + return binToHex(bytes); + } + + public getVersion() { + return this.transaction.version; + } + + public getInputs() { + return this.transaction.inputs; + } + + public getOutputs() { + return this.transaction.outputs; + } + + public getLocktime() { + return this.transaction.locktime; + } +} diff --git a/src/utils/event-emitter.ts b/src/utils/event-emitter.ts new file mode 100644 index 0000000..e9ca3b7 --- /dev/null +++ b/src/utils/event-emitter.ts @@ -0,0 +1,61 @@ +export class EventEmitter< + T extends Record +> extends EventTarget { + private listeners: Map> = new Map(); + + constructor() { + super(); + } + + on(type: K, listener: (detail: T[K]) => void) { + const wrappedListener = ((e: CustomEvent) => + listener(e.detail)) as EventListener; + this.addEventListener(type as string, wrappedListener); + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + this.listeners.get(type)?.add(wrappedListener); + + return wrappedListener; + } + + off(type: K, listener: EventListener) { + this.removeEventListener(type as string, listener); + this.listeners.get(type)?.delete(listener); + } + + emit(type: K, detail: T[K]) { + const event = new CustomEvent(type as string, { detail }); + return this.dispatchEvent(event); + } + + removeAllListeners() { + this.listeners.forEach((listeners, type) => { + listeners.forEach((listener) => { + this.removeEventListener(type as string, listener); + }); + }); + this.listeners.clear(); + } + + monitorProperty( + target: ObjectType, + key: Property, + eventName: keyof T + ) { + let value = target[key]; + + Object.defineProperty(target, key, { + get: () => { + return value; + }, + set: (newValue) => { + value = newValue; + this.emit(eventName as string, newValue); + }, + enumerable: true, + configurable: true, + }); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..757c42f --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './event-emitter.js'; diff --git a/src/vue/index.ts b/src/vue/index.ts new file mode 100644 index 0000000..eb66c6f --- /dev/null +++ b/src/vue/index.ts @@ -0,0 +1,2 @@ +export * from './wallet-hd-vue.js'; +export * from './wallet-p2pkh-vue.js'; diff --git a/src/vue/wallet-hd-vue.ts b/src/vue/wallet-hd-vue.ts new file mode 100644 index 0000000..d00605a --- /dev/null +++ b/src/vue/wallet-hd-vue.ts @@ -0,0 +1,39 @@ +import { ElectrumService } from 'src/services/electrum.js'; +import { WalletHD } from 'src/libcash/wallet/index.js'; +import { WalletP2PKHVue, useWalletP2PKHVue } from './wallet-p2pkh-vue.js'; + +import { computed, ref, shallowRef } from 'vue'; + +import { HdPrivateNodeValid } from '@bitauth/libauth'; + +export class WalletHDVue extends WalletHD { + // Reactive State. + public rShouldMonitor = ref(false); + public rWallets = shallowRef>([]); + + // Computeds. + public rBalance = computed(() => { + return this.rWallets.value.reduce( + (total, node) => total + node.rBalance.value, + 0 + ); + }); + + constructor( + node: HdPrivateNodeValid, + electrum: ElectrumService, + walletFactory = useWalletP2PKHVue + ) { + super(node, electrum, walletFactory); + + this.events.on( + 'walletsUpdated', + (wallets) => (this.rWallets.value = wallets) + ); + + this.events.on( + 'shouldMonitorUpdated', + (shouldMonitor) => (this.rShouldMonitor.value = shouldMonitor) + ); + } +} diff --git a/src/vue/wallet-p2pkh-vue.ts b/src/vue/wallet-p2pkh-vue.ts new file mode 100644 index 0000000..f2de4b9 --- /dev/null +++ b/src/vue/wallet-p2pkh-vue.ts @@ -0,0 +1,40 @@ +import { WalletP2PKH } from 'src/libcash/wallet/index.js'; + +import { type AddressListUnspent } from 'src/services/electrum-types'; +import { ElectrumService } from 'src/services/electrum.js'; +import { Transaction } from 'src/libcash/primitives/index.js'; + +import { computed, shallowRef } from 'vue'; + +export class WalletP2PKHVue extends WalletP2PKH { + // Reactive State. + public rUnspents = shallowRef([]); + public rTransactions = shallowRef>([]); + + // Computeds. + public rBalance = computed(() => { + return this.rUnspents.value.reduce( + (total, unspent) => total + unspent.value, + 0 + ); + }); + + constructor(privateKeyBytes: Uint8Array, electrum: ElectrumService) { + super(privateKeyBytes, electrum); + + this.events.on('unspentsUpdated', (unspents) => { + this.rUnspents.value = unspents; + }); + + this.events.on('transactionsUpdated', (transactions) => { + this.rTransactions.value = transactions; + }); + } +} + +export function useWalletP2PKHVue( + privateKeyBytes: Uint8Array, + electrum: ElectrumService +): WalletP2PKHVue { + return new WalletP2PKHVue(privateKeyBytes, electrum); +} diff --git a/src/wallet/config.ts b/src/wallet/config.ts new file mode 100644 index 0000000..d7d16bf --- /dev/null +++ b/src/wallet/config.ts @@ -0,0 +1,9 @@ +import { ElectrumService } from 'src/services/electrum.js'; + +export type ServiceConfig = { + createBlockchain: () => BlockchainType; + createWallet: ( + privateKeyBytes: Uint8Array, + electrum: ElectrumService + ) => WalletType; +}; diff --git a/src/wallet/index.ts b/src/wallet/index.ts new file mode 100644 index 0000000..0c00599 --- /dev/null +++ b/src/wallet/index.ts @@ -0,0 +1,2 @@ +export * from './wallet-hd.js'; +export * from './wallet-p2pkh.js'; diff --git a/src/wallet/wallet-base.ts b/src/wallet/wallet-base.ts new file mode 100644 index 0000000..38c2026 --- /dev/null +++ b/src/wallet/wallet-base.ts @@ -0,0 +1,52 @@ +import { PrivateKey, Transaction } from 'src/libcash/primitives/index.js'; +import { EventEmitter } from 'src/libcash/utils/index.js'; +import { type AddressListUnspent } from 'src/services/electrum-types'; +import { ElectrumService } from 'src/services/electrum'; + +import { + type CompilerBCH, + type CompilationData, + type InputTemplate, +} from '@bitauth/libauth'; + +export type InputTemplateBCH = InputTemplate< + CompilerBCH, + false, + CompilationData +>; + +export abstract class WalletBase extends PrivateKey { + // Dependencies/Services + public electrum: ElectrumService; + + constructor(privateKeyBytes: Uint8Array, electrum: ElectrumService) { + super(privateKeyBytes); + this.electrum = electrum; + } + + abstract startMonitoring(): Promise; + + abstract stopMonitoring(): Promise; + + abstract getAddress(): string; + + abstract fetchHistory(): Promise>; + + abstract fetchUnspentOutputs(): Promise; + + abstract getUnspentDirectives(): Promise>; + + async refresh(): Promise { + await Promise.all([this.fetchUnspentOutputs(), this.fetchHistory()]); + } + + async onAddressNotification(status: string | null) { + // If status is null, it simply means that our subscribe call to Electrum was successful. + if (!status) { + return; + } + + // Refresh our wallet's state. + await this.refresh(); + } +} diff --git a/src/wallet/wallet-hd.ts b/src/wallet/wallet-hd.ts new file mode 100644 index 0000000..8c94103 --- /dev/null +++ b/src/wallet/wallet-hd.ts @@ -0,0 +1,251 @@ +// Import the Stamp class to derive the private keys +import { HDPrivateNode } from 'src/libcash/primitives/index.js'; +import { EventEmitter } from 'src/libcash/utils/index.js'; +import { ElectrumService } from 'src/services/electrum'; +import { + WalletP2PKH, + WalletP2PKHFactory, + useWalletP2PKH, +} from './wallet-p2pkh.js'; + +import { + type HdPrivateNodeValid, + deriveSeedFromBip39Mnemonic, + encodeTransaction, + generateTransaction, + getMinimumFee, +} from '@bitauth/libauth'; + +export const DERIVATION_PATH = "m/44'/145'/0'"; +export const ADDRESS_GAP = 20; + +export type WalletHDEvents = { + shouldMonitorUpdated: boolean; + walletsUpdated: Array; +}; + +export class WalletHD< + WalletType extends WalletP2PKH = WalletP2PKH +> extends HDPrivateNode { + // Events. + public events: EventEmitter> = new EventEmitter(); + + // Mutable State. + public shouldMonitor = false; + public wallets: Array = []; + + constructor( + public node: HdPrivateNodeValid, + public electrum: ElectrumService, + private walletFactory: WalletP2PKHFactory = useWalletP2PKH + ) { + // Instantiate parent class. + super(node); + + // Monitor the following properties and emit an event when they change. + this.events.monitorProperty(this, 'shouldMonitor', 'shouldMonitorUpdated'); + this.events.monitorProperty(this, 'wallets', 'walletsUpdated'); + } + + static fromMnemonic>( + this: WalletHDConstructor, + mnemonic: string, + electrum: ElectrumService, + walletFactory?: WalletP2PKHFactory + ): T { + // Derive the seed from the mnemonic. + const seed = deriveSeedFromBip39Mnemonic(mnemonic); + + // Derive node from mnemonic. + const hdPrivateNode = HDPrivateNode.fromSeed(seed); + + // Return a new instance of this class. + return new this(hdPrivateNode.node, electrum, walletFactory); + } + + async destroy() { + this.events.removeAllListeners(); + } + + async setQuantity(quantity: number) { + // Clear existing nodes. + this.wallets = this.deriveWallets(quantity); + } + + async startMonitoring() { + this.shouldMonitor = true; + + await Promise.all(this.wallets.map((wallet) => wallet.startMonitoring())); + } + + async stopMonitoring() { + this.shouldMonitor = false; + + await Promise.all(this.wallets.map((wallet) => wallet.stopMonitoring())); + } + + deriveWallets(count: number, startIndex = 0) { + // Create an array to store our wallets. + const wallets: Array = []; + + // Create the given count of wallets. + for (let i = 0; i < count; i++) { + const childNode = this.derivePath( + `${DERIVATION_PATH}/0/${startIndex + i}` + ); + const wallet = this.walletFactory( + childNode.privateKey().toBytes(), + this.electrum + ); + + if (this.shouldMonitor) { + wallet.startMonitoring(); + } + + wallets.push(wallet); + } + + this.wallets = wallets; + + // Return the wallets. + return wallets; + } + + async scan(startIndex = 0, addressGap = 20) { + // Array to store active wallet nodes + const nodes: Array = []; + + let currentIndex = startIndex; + let emptyAddressCount = 0; + + // Continue scanning until we find 'addressGap' consecutive empty addresses + while (emptyAddressCount < addressGap) { + // Derive a number of wallets equivalent to our addressGap. + const batch: Array = this.deriveWallets( + addressGap, + currentIndex + ); + + // Get the transaction history of each wallet in the batch + const results = await Promise.all( + batch.map((wallet) => wallet.fetchHistory()) + ); + + // Process the results + for (let i = 0; i < results.length; i++) { + if (results[i].length > 0) { + // This address has transactions, add it to nodes and reset empty address count + nodes.push(batch[i]); + emptyAddressCount = 0; + } else { + // This address is empty, increment the empty address count + emptyAddressCount++; + } + + currentIndex++; + + // If we've found 'addressGap' consecutive empty addresses, stop scanning + if (emptyAddressCount >= addressGap) { + break; + } + } + } + + this.wallets = nodes; + + return nodes; + } + + async refreshChildNodes() { + await Promise.all(this.wallets.map((node) => node.refresh())); + } + + async sweep(payoutBytecode: Uint8Array) { + // Get a list of inputs belonging to each wallet. + const inputs = await Promise.all( + this.wallets.map((wallet) => wallet.getUnspentDirectives()) + ); + + // Flatten the inputs. + const inputsFlattened = inputs.flat(); + + // Calculate the total sats available in our inputs. + const totalSats = inputsFlattened.reduce((total, input) => { + if (input.unlockingBytecode instanceof Uint8Array) { + return (total += 0n); + } + + return (total += input.unlockingBytecode.valueSatoshis); + }, 0n); + + /* + // TODO: Determine the amounts and categories of any tokens in the inputs. + const tokens = inputsFlattened.reduce((tokens, input) => { + // Improve legibility by getting the token. + const token = input.unlockingBytecode.token; + + if(!token) { + return tokens; + } + + tokens[token. + + return tokens; + }, {}); + */ + // TODO: Add an output for each token. + + // We need to calculate the number of bytes so that we can calculate the fee. + // So we loop twice and store the final transaction here each time. + // 1st time will have zero fee. 2nd time will accommodate the fee. + let encodedTransaction = new Uint8Array(); + + // Create the transaction by looping twice. + // 1st loop: Transaction without a fee. + // 2nd loop: Accommodate the fee. + for (let i = 0; i < 2; i++) { + // Get the fee using 1000 sats/KB. + const feeSats = getMinimumFee(BigInt(encodedTransaction.length), 1000n); + + // Attempt to generate the transaction. + const generatedTransaction = generateTransaction({ + version: 2, + locktime: 0, + inputs: inputsFlattened, + outputs: [ + { + lockingBytecode: payoutBytecode, + valueSatoshis: totalSats - feeSats, + }, + ], + }); + + if (!generatedTransaction.success) { + console.error(generatedTransaction.errors); + + throw new Error('Failed to generate transaction'); + } + + // Encode the transaction for broadcasting (and fee estimation). + encodedTransaction = encodeTransaction(generatedTransaction.transaction); + } + + return encodedTransaction; + } +} + +//----------------------------------------------------------------------------- +// Factories Types/Functions +//----------------------------------------------------------------------------- + +export type WalletHDParams = ConstructorParameters; +export type WalletHDConstructor = new (...args: WalletHDParams) => T; +export type WalletHDFactory = ( + ...args: WalletHDParams +) => T; + +export function useWalletHD( + ...args: WalletHDParams +): T { + return new WalletHD(...args) as T; +} diff --git a/src/wallet/wallet-p2pkh.ts b/src/wallet/wallet-p2pkh.ts new file mode 100644 index 0000000..8232fc7 --- /dev/null +++ b/src/wallet/wallet-p2pkh.ts @@ -0,0 +1,173 @@ +import { Transaction } from 'src/libcash/primitives/index.js'; +import { EventEmitter } from 'src/libcash/utils/index.js'; +import { + type AddressListUnspent, + type AddressGetHistory, + type TransactionGet, +} from 'src/services/electrum-types'; +import { ElectrumService } from 'src/services/electrum'; + +import { WalletBase } from './wallet-base.js'; + +import { + hexToBin, + walletTemplateP2pkhNonHd, + walletTemplateToCompilerBCH, +} from '@bitauth/libauth'; + +export type WalletP2PKHEvents = { + transactionsUpdated: Array; + unspentsUpdated: AddressListUnspent['response']; +}; + +export class WalletP2PKH extends WalletBase { + // Events. + public events: EventEmitter = new EventEmitter(); + + // State. + public transactions: Array = []; + public unspents: AddressListUnspent['response'] = []; + + constructor(privateKeyBytes: Uint8Array, electrum: ElectrumService) { + super(privateKeyBytes, electrum); + + // Monitor the following properties and emit an event when they change. + this.events.monitorProperty(this, 'transactions', 'transactionsUpdated'); + this.events.monitorProperty(this, 'unspents', 'unspentsUpdated'); + } + + async destroy() { + this.events.removeAllListeners(); + } + + async startMonitoring() { + await this.electrum.subscribeAddress( + this.getAddress(), + this.onAddressNotification.bind(this) + ); + } + + async stopMonitoring() { + await this.electrum.unsubscribeAddress( + this.getAddress(), + this.onAddressNotification.bind(this) + ); + } + + getAddress() { + return this.derivePublicKey().deriveAddress().toCashAddr(); + } + + async fetchHistory() { + // Get this node's address. + const address = this.getAddress(); + + // Get the history of the address from electrum + const history = await this.electrum.request( + 'blockchain.address.get_history', + address, + 0, + -1 + ); + + // Get the transactions for each history item. + const transactions = await Promise.all( + history.map((item) => + this.electrum.request( + 'blockchain.transaction.get', + item.tx_hash, + false + ) + ) + ); + + // Decode the transactions. + const decodedTransactions = transactions.map((tx) => + Transaction.fromHex(tx) + ); + + // Store the transactions. + this.transactions = decodedTransactions; + + return decodedTransactions; + } + + async fetchUnspentOutputs() { + const address = this.getAddress(); + + const unspentOutputs = await this.electrum.request( + 'blockchain.address.listunspent', + address, + 'include_tokens' + ); + + // Emit event with unspent outputs. + this.unspents = unspentOutputs; + + return unspentOutputs; + } + + async getUnspentDirectives() { + // Get a list of unspent outputs. + const unspentOutputs = await this.fetchUnspentOutputs(); + + // Create our P2PKH Compiler. + const compilerP2PKH = walletTemplateToCompilerBCH(walletTemplateP2pkhNonHd); + + // Transform our inputs into unlocking directives. + const inputDirectives = unspentOutputs.map((unspent) => ({ + outpointIndex: unspent.tx_pos, + outpointTransactionHash: hexToBin(unspent.tx_hash), + sequenceNumber: 0, + unlockingBytecode: { + compiler: compilerP2PKH, + data: { + keys: { privateKeys: { key: this.toBytes() } }, + }, + script: 'unlock', + token: unspent.token_data + ? { + amount: BigInt(unspent.token_data.amount), + category: hexToBin(unspent.token_data.category), + nft: unspent.token_data.nft + ? { + capability: unspent.token_data.nft.capability, + commitment: hexToBin(unspent.token_data.nft.commitment), + } + : undefined, + } + : undefined, + valueSatoshis: BigInt(unspent.value), + }, + })); + + // Return the directives. + return inputDirectives; + } + + async refresh(): Promise { + await Promise.all([this.fetchUnspentOutputs(), this.fetchHistory()]); + } + + async onAddressNotification(status: string | null) { + // If status is null, it simply means that our subscribe call to Electrum was successful. + if (!status) { + return; + } + + // Refresh our wallet's state. + await this.refresh(); + } +} + +export type WalletP2PKHFactory = ( + privateKeyBytes: Uint8Array, + electrum: ElectrumService +) => T; + +export function useWalletP2PKH( + privateKeyBytes: Uint8Array, + electrum: ElectrumService +): T { + return new WalletP2PKH(privateKeyBytes, electrum) as T; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4db0c0c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + // Target esnext (rollup step will transpile to CJS and EJS) + "target": "esnext", + + // Use node as module resolution strategy for best compatibility with dependencies + "moduleResolution": "node", + "module": "esnext", + + // Set the root directory and output directory + "rootDir": "./src", + "outDir": "./build/lib", + + // Allow ES modules to be used + "esModuleInterop": true, + + // Allow importing .json files in project + "resolveJsonModule": true, + + // Generate declarations, declaration maps and sourcemaps + "declaration": true, + "declarationMap": true, + "sourceMap": true, + + // Use strict Typescript rules + "strict": true, + "strictPropertyInitialization": true, + + // Allow use of ES2017 and DOM features/libraries + "lib": ["es2017", "dom"] + }, + + // Exclude these directories from the Typescript transpile + "exclude": ["node_modules/**", "dist/**", "public/**"], + + // Do not compile upon save - this feature requires tsc-w(atch) + "compileOnSave": true +}