diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index fbaf17c..895dfd3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -30,6 +30,12 @@ export default defineConfig({ title: 'XO Stack', description: 'XO Stack', themeConfig: { + // Set the logo + logo: { + light: '/logo-light.svg', + dark: '/logo-dark.svg', + }, + // Enable search functionality. search: { provider: 'local', diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue new file mode 100644 index 0000000..5970ec5 --- /dev/null +++ b/docs/.vitepress/theme/Layout.vue @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index def4cfc..9e01c32 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,17 +1,13 @@ // https://vitepress.dev/guide/custom-theme -import { h } from 'vue' -import type { Theme } from 'vitepress' -import DefaultTheme from 'vitepress/theme' -import './style.css' +import type { Theme } from 'vitepress'; +import DefaultTheme from 'vitepress/theme'; +import Layout from './Layout.vue'; +import './style.css'; export default { extends: DefaultTheme, - Layout: () => { - return h(DefaultTheme.Layout, null, { - // https://vitepress.dev/guide/extending-default-theme#layout-slots - }) - }, + Layout, enhanceApp({ app, router, siteData }) { // ... - } -} satisfies Theme + }, +} satisfies Theme; diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css index d63aee8..0ac631e 100644 --- a/docs/.vitepress/theme/style.css +++ b/docs/.vitepress/theme/style.css @@ -70,6 +70,22 @@ --vp-c-danger-soft: var(--vp-c-red-soft); } +.VPSidebarItem.level-2.is-active > .item > .indicator, +.VPSidebarItem.level-3.is-active > .item > .indicator, +.VPSidebarItem.level-4.is-active > .item > .indicator, +.VPSidebarItem.level-5.is-active > .item > .indicator { + background-color: #b2fb54 !important; +} + +.VPSidebarItem.level-0.is-active > .item .link > .text, +.VPSidebarItem.level-1.is-active > .item .link > .text, +.VPSidebarItem.level-2.is-active > .item .link > .text, +.VPSidebarItem.level-3.is-active > .item .link > .text, +.VPSidebarItem.level-4.is-active > .item .link > .text, +.VPSidebarItem.level-5.is-active > .item .link > .text { + color: #b2fb54 !important; +} + /** * Component: Button * -------------------------------------------------------------------------- */ diff --git a/docs/logo-dark.svg b/docs/logo-dark.svg new file mode 100644 index 0000000..a8e5dd5 --- /dev/null +++ b/docs/logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/logo-light.svg b/docs/logo-light.svg new file mode 100644 index 0000000..675c98d --- /dev/null +++ b/docs/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/primitives/package.json b/packages/primitives/package.json index fd680c6..468d1b6 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -10,8 +10,8 @@ "dist" ], "scripts": { - "build": "tsc", - "build-old": "rollup -c" + "build": "npm run clean && tsc", + "clean": "rm -r ./dist" }, "author": "", "license": "ISC" diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 9a80c35..129f108 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -8,3 +8,5 @@ export * from './public-key.js'; export * from './satoshis.js'; export * from './script.js'; export * from './transaction.js'; +export * from './transaction-graph.js'; +export * from './transaction-hash.js'; diff --git a/packages/primitives/src/transaction-graph.ts b/packages/primitives/src/transaction-graph.ts new file mode 100644 index 0000000..7e3a182 --- /dev/null +++ b/packages/primitives/src/transaction-graph.ts @@ -0,0 +1,125 @@ +import { Transaction } from '@xo/primitives'; + +import { binToHex, Output } from '@bitauth/libauth'; + +export interface UTXO extends Output { + outpointTransactionHash: string; + outpointIndex: number; +} + +export class TransactionGraph { + private transactions: Map; + + constructor() { + this.transactions = new Map(); + } + + addTransaction(tx: Transaction) { + this.transactions.set(tx.toHashHex(), tx); + } + + removeTransaction(txHash: string) { + this.transactions.delete(txHash); + } + + getTransaction(txHash: string): Transaction | undefined { + return this.transactions.get(txHash); + } + + getAllTransactions(): Transaction[] { + return Array.from(this.transactions.values()); + } + + deriveUtxoState(): UTXO[] { + const utxoMap = new Map(); + + // First, add all outputs as potential UTXOs + for (const tx of this.transactions.values()) { + tx.getOutputs().forEach((output, outpointIndex) => { + const txHashHex = tx.toHashHex(); + + const utxo: UTXO = { + outpointTransactionHash: txHashHex, + outpointIndex, + valueSatoshis: output.valueSatoshis, + lockingBytecode: output.lockingBytecode, + }; + utxoMap.set(`${txHashHex}:${outpointIndex}`, utxo); + }); + } + + // Then, remove all spent outputs + for (const tx of this.transactions.values()) { + tx.getInputs().forEach((input) => { + const outpointTransactionHashHex = binToHex( + input.outpointTransactionHash + ); + + utxoMap.delete(`${outpointTransactionHashHex}:${input.outpointIndex}`); + }); + } + + return Array.from(utxoMap.values()); + } + + isTransactionValid(tx: Transaction): boolean { + const currentUtxos = this.deriveUtxoState(); + const utxoMap = new Map( + currentUtxos.map((utxo) => [ + `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`, + utxo, + ]) + ); + + return tx.getInputs().every((input) => { + const outpointTransactionHashHex = binToHex( + input.outpointTransactionHash + ); + return utxoMap.has( + `${outpointTransactionHashHex}:${input.outpointIndex}` + ); + }); + } + + getDependentTransactions(txHash: string): Transaction[] { + return this.getAllTransactions().filter((tx) => { + tx.getInputs().some((input) => { + const outpointTransactionHashHex = binToHex( + input.outpointTransactionHash + ); + return outpointTransactionHashHex === txHash; + }); + }); + } + + getBroadcastOrder(): Transaction[] { + const visited = new Set(); + const order: Transaction[] = []; + + const visit = (txid: string) => { + if (visited.has(txid)) return; + + const tx = this.transactions.get(txid); + if (!tx) return; + + visited.add(txid); + + // First, visit all transactions that this transaction depends on + for (const input of tx.getInputs()) { + const outpointTransactionHashHex = binToHex( + input.outpointTransactionHash + ); + visit(outpointTransactionHashHex); + } + + order.push(tx); + }; + + // Start the topological sort from each transaction + for (const tx of this.transactions.values()) { + visit(tx.toHashHex()); + } + + return order; + } +} diff --git a/packages/primitives/src/transaction-hash.ts b/packages/primitives/src/transaction-hash.ts new file mode 100644 index 0000000..01e8db5 --- /dev/null +++ b/packages/primitives/src/transaction-hash.ts @@ -0,0 +1,106 @@ +import { + binToHex, + hexToBin, + hashTransaction, + isHex, +} from '@bitauth/libauth'; + +/** + * Transaction Hash Primitive. + */ +export class TransactionHash +{ + public readonly bytes: Uint8Array; + + /** + * Instantiates TransactionHash from a Transaction Hash's bytes. + * + * @param bytes {Uint8Array} The Transaction Hash's bytes. + */ + constructor(bytes: Uint8Array) + { + // If bytes is not exactly 32, then this is an invalid transaction hash... + if(bytes.length !== 32) + { + throw(new Error(`Invalid Transaction Hash: Expected 32 bytes but received ${bytes.length}`)); + } + + this.bytes = bytes; + } + + /** + * Instantiates TransactionHash from a hex string. + * + * @param hex {string} The hex string. + * + * @throws {Error} If hex is invalid. + * + * @returns {TransactionHash} Instance of TransactionHash. + */ + public static fromHex(hex: string): TransactionHash + { + if(!isHex(hex)) + { + throw(new Error('Invalid hex provided')); + } + + const bytes = hexToBin(hex); + + return new TransactionHash(bytes); + } + + /** + * Instantiates TransactionHash from a Transaction's bytes. + * + * @param transactionBytes {Uint8Array} The Transaction's bytes. + * + * @returns {TransactionHash} Instance of TransactionHash. + */ + public static fromTransactionBytes(transactionBytes: Uint8Array): TransactionHash + { + const transactionHash = hashTransaction(transactionBytes); + + return new TransactionHash(hexToBin(transactionHash)); + } + + /** + * Instantiates TransactionHash from a Transaction's bytes in hex. + * + * @param transactionHex {Uint8Array} The Transaction's bytes in hex. + * + * @throws {Error} If hex is invalid. + * + * @returns {TransactionHash} Instance of TransactionHash. + */ + public static fromTransactionHex(transactionHex: string): TransactionHash + { + if(!isHex(transactionHex)) + { + throw(new Error('Invalid hex provided')); + } + + const bytes = hexToBin(transactionHex); + + return TransactionHash.fromTransactionBytes(bytes); + } + + /** + * Returns transaction hash as a hex string. + * + * @returns {string} Hex string of the Transaction Hash. + */ + public toHex(): string + { + return binToHex(this.bytes); + } + + /** + * Returns transaction hash as a Uint8Array. + * + * @returns {Uint8Array} Uint8Array of the Transaction Hash. + */ + public toBytes(): Uint8Array + { + return this.bytes; + } +} diff --git a/packages/primitives/src/transaction.ts b/packages/primitives/src/transaction.ts index 1f03cba..f9c5be7 100644 --- a/packages/primitives/src/transaction.ts +++ b/packages/primitives/src/transaction.ts @@ -1,3 +1,5 @@ +import { TransactionHash } from './transaction-hash.js'; + import { binToHex, hexToBin, @@ -69,6 +71,18 @@ export class Transaction { return binToHex(bytes); } + public toHash(): TransactionHash { + return TransactionHash.fromTransactionBytes(this.toBytes()); + } + + public toHashHex(): string { + return this.toHash().toHex(); + } + + public toHashBytes(): Uint8Array { + return this.toHash().toBytes(); + } + public getVersion() { return this.transaction.version; } diff --git a/packages/wallet/src/wallet/blockchain.ts b/packages/wallet/src/wallet/blockchain.ts index cec3f48..91ea11d 100644 --- a/packages/wallet/src/wallet/blockchain.ts +++ b/packages/wallet/src/wallet/blockchain.ts @@ -1,3 +1,4 @@ +import { Transaction } from '@xo/primitives'; import { AddressNotification, ElectrumRequest } from './blockchain-types.js'; import { ElectrumClient } from '@electrum-cash/network'; @@ -75,6 +76,22 @@ export class Blockchain { return response; } + // TODO: async commit() method + // This won't actually broadcast, this will just commit the transaction to our graph. + // The reason we want this is because, for protos like BIP70, we want service to verify the tx and broadcast it. + // And we just want to be able to monitor whether service did the needful (broadcasted it). + // This should accept options: { waitForSeen: boolean } + async commitTransaction( + transaction: Transaction, + opts: { waitUntilSeen: boolean } = { waitUntilSeen: true } + ) { + console.log(transaction); + console.log(opts); + } + + // TODO: async broadcast() method + // This should accept options: { waitForSeen: boolean } + async subscribeAddress(address: string, callback: AddressCallback) { await this.electrumClient.subscribe( 'blockchain.address.subscribe',