From f2210e631aad8a05d0203fcbad327ec6a026614c Mon Sep 17 00:00:00 2001 From: James Zuccon Date: Fri, 16 Aug 2024 22:06:01 +1000 Subject: [PATCH] Initial commit Signed-off-by: James Zuccon --- .editorconfig | 9 + .eslintignore | 3 + .eslintrc | 13 + .eslintrc.cjs | 65 +++++ .gitignore | 10 + .npmrc | 3 + .prettierrc | 4 + README.md | 3 + jsdoc.json | 35 +++ package.json | 60 +++++ rollup.config.js | 30 +++ src/primitives/address.ts | 422 ++++++++++++++++++++++++++++++ src/primitives/hd-private-node.ts | 178 +++++++++++++ src/primitives/hd-public-node.ts | 112 ++++++++ src/primitives/index.ts | 7 + src/primitives/private-key.ts | 310 ++++++++++++++++++++++ src/primitives/public-key.ts | 188 +++++++++++++ src/primitives/satoshis.ts | 166 ++++++++++++ src/primitives/transaction.ts | 87 ++++++ src/utils/event-emitter.ts | 61 +++++ src/utils/index.ts | 1 + src/vue/index.ts | 2 + src/vue/wallet-hd-vue.ts | 39 +++ src/vue/wallet-p2pkh-vue.ts | 40 +++ src/wallet/config.ts | 9 + src/wallet/index.ts | 2 + src/wallet/wallet-base.ts | 52 ++++ src/wallet/wallet-hd.ts | 251 ++++++++++++++++++ src/wallet/wallet-p2pkh.ts | 173 ++++++++++++ tsconfig.json | 38 +++ 30 files changed, 2373 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .eslintrc.cjs create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 jsdoc.json create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 src/primitives/address.ts create mode 100644 src/primitives/hd-private-node.ts create mode 100644 src/primitives/hd-public-node.ts create mode 100644 src/primitives/index.ts create mode 100644 src/primitives/private-key.ts create mode 100644 src/primitives/public-key.ts create mode 100644 src/primitives/satoshis.ts create mode 100644 src/primitives/transaction.ts create mode 100644 src/utils/event-emitter.ts create mode 100644 src/utils/index.ts create mode 100644 src/vue/index.ts create mode 100644 src/vue/wallet-hd-vue.ts create mode 100644 src/vue/wallet-p2pkh-vue.ts create mode 100644 src/wallet/config.ts create mode 100644 src/wallet/index.ts create mode 100644 src/wallet/wallet-base.ts create mode 100644 src/wallet/wallet-hd.ts create mode 100644 src/wallet/wallet-p2pkh.ts create mode 100644 tsconfig.json 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 +}