From dffa72d8507dee8ee427822493c10d9ab8c512cd Mon Sep 17 00:00:00 2001 From: devperate Date: Mon, 25 Aug 2025 03:57:00 +0000 Subject: [PATCH] Resolve "Blockchain Adapter: Add Chaingraph Adapter" --- package-lock.json | 18 +- packages/stack/package.json | 3 +- .../blockchain-chaingraph-helpers.ts | 485 +++++++++ .../src/blockchain/blockchain-chaingraph.ts | 979 ++++++++++++++++++ .../src/blockchain/blockchain-electrum.ts | 6 +- packages/stack/src/blockchain/types.ts | 6 + packages/stack/src/index.ts | 1 + packages/stack/src/utils/graphql-client.ts | 465 +++++++++ packages/stack/src/utils/index.ts | 2 + packages/stack/src/utils/memoizer.ts | 4 +- packages/stack/src/utils/ws-client.ts | 250 +++++ packages/stack/src/wallet/base-wallet.ts | 2 +- .../tests/blockchain/blockchain-e2e.spec.ts | 148 +++ .../stack/tests/utils/graphql-client.spec.ts | 184 ++++ .../tests/utils/json-to-graphql-query.spec.ts | 557 ++++++++++ packages/stack/tsconfig.json | 13 +- 16 files changed, 3103 insertions(+), 20 deletions(-) create mode 100644 packages/stack/src/blockchain/blockchain-chaingraph-helpers.ts create mode 100644 packages/stack/src/blockchain/blockchain-chaingraph.ts create mode 100644 packages/stack/src/utils/graphql-client.ts create mode 100644 packages/stack/src/utils/ws-client.ts create mode 100644 packages/stack/tests/blockchain/blockchain-e2e.spec.ts create mode 100644 packages/stack/tests/utils/graphql-client.spec.ts create mode 100644 packages/stack/tests/utils/json-to-graphql-query.spec.ts diff --git a/package-lock.json b/package-lock.json index cce27f3..5796767 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8916,6 +8916,12 @@ "dev": true, "license": "ISC" }, + "node_modules/json-to-graphql-query": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/json-to-graphql-query/-/json-to-graphql-query-2.3.0.tgz", + "integrity": "sha512-khZtaLLQ0HllFec+t89ZWduUZ0rmne/OpRm/39hyZUWDHNx9Yk4DgQzDtMeqd8zj2g5opBD4GHrdtH0JzKnN2g==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -14824,14 +14830,24 @@ "@xocash/primitives": "*", "better-sqlite3": "^11.8.1", "idb": "8.0.3", + "json-to-graphql-query": "^2.3.0", "msgpackr": "^1.11.2", - "zod": "^3.24.2" + "zod": "^4.0.10" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "rollup": "^4.45.0", "rollup-plugin-dts": "^6.2.1" } + }, + "packages/stack/node_modules/zod": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.10.tgz", + "integrity": "sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/packages/stack/package.json b/packages/stack/package.json index 74cf06a..53ad8c2 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -42,8 +42,9 @@ "@xocash/primitives": "*", "better-sqlite3": "^11.8.1", "idb": "8.0.3", + "json-to-graphql-query": "^2.3.0", "msgpackr": "^1.11.2", - "zod": "^3.24.2" + "zod": "^4.0.10" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", diff --git a/packages/stack/src/blockchain/blockchain-chaingraph-helpers.ts b/packages/stack/src/blockchain/blockchain-chaingraph-helpers.ts new file mode 100644 index 0000000..f4a43d8 --- /dev/null +++ b/packages/stack/src/blockchain/blockchain-chaingraph-helpers.ts @@ -0,0 +1,485 @@ +import { EnumType, VariableType } from 'json-to-graphql-query'; +import { z } from 'zod'; + +export type ChainNames = 'bchn-mainnet' | 'bchn-chipnet' | 'bchn-testnet'; + +// NOTE: Following there are a list of GraphQL queries written in json alike format, +// we are using this standard to avoid write queries using string literals which are prone to errors. +// These queries are being used by BlockchainChaingraph Bridge Class. +// Check out: https://www.npmjs.com/package/json-to-graphql-query +// for more info about how to write these. + +/** + * Subscription query to be notified about new blocks on a given chain. + */ +export const monitorSyncTipsSubscriptionQuery = { + subscription: { + __name: 'MonitorSyncTips', + __variables: { + chainFilter: 'String = "bchn-mainnet"', // Default to 'bchn-mainnet' + }, + node: { + __args: { + where: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + name: true, + internal_id: true, + user_agent: true, + latest_tip: { + __aliasFor: 'accepted_blocks', + __args: { + limit: 1, + order_by: { block_internal_id: new EnumType('desc') }, + }, + accepted_at: true, + block: { + hash: true, + height: true, + }, + }, + }, + }, +}; + +/** + * Query to fetch outputs of a given locking Bytecode Hex. + */ +export const searchOutputsByLockingBytecodeQuery = { + query: { + __name: 'SearchOutputsByLockingBytecode', + __variables: { + chainFilter: 'String = "bchn-mainnet"', + lockingBytecodeHexObject: '_text', + }, + search_output: { + __args: { + args: { + locking_bytecode_hex: new VariableType('lockingBytecodeHexObject'), + }, + where: { + _not: { spent_by: {} }, + transaction: { + block_inclusions: { + block: { + accepted_by: { + node: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + }, + }, + }, + }, + }, + locking_bytecode: true, + output_index: true, + transaction_hash: true, + value_satoshis: true, + token_category: true, + fungible_token_amount: true, + nonfungible_token_capability: true, + nonfungible_token_commitment: true, + transaction: { + block_inclusions: { + block: { + height: true, + }, + }, + }, + spent_by: { + input_index: true, + }, + }, + }, +}; + +/** + * Query to fetch the transaction Hex of a given transaction Hash. + */ +export const getTransactionHexQuery = { + query: { + __name: 'GetTransactionHex', + __variables: { + chainFilter: 'String = "bchn-mainnet"', + txHash: 'bytea', + }, + transaction: { + __args: { + where: { + hash: { _eq: new VariableType('txHash') }, + block_inclusions: { + block: { + accepted_by: { + node: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + }, + }, + }, + }, + encoded_hex: true, + }, + }, +}; + +/** + * Query to fetch the block Header Hex of a given blockHeight. + */ +export const getBlockHeaderHexQuery = { + query: { + __name: 'GetBlockHeaderHex', + __variables: { + chainFilter: 'String = "bchn-mainnet"', + blockHeight: 'bigint', + }, + block: { + __args: { + where: { + height: { _eq: new VariableType('blockHeight') }, + accepted_by: { + block: { + accepted_by: { + node: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + }, + }, + }, + }, + header: true, + }, + }, +}; + +/** + * Subscription query to be notified about payments made to a lockscript Hex. + */ +export const monitorForPaymentsSubscriptionQuery = { + subscription: { + __name: 'MonitorForPayments', + __variables: { + lockscriptHexObject: '_text', + }, + search_output: { + __args: { + args: { locking_bytecode_hex: new VariableType('lockscriptHexObject') }, + where: { + transaction: { + _not: { block_inclusions: {} }, + }, + }, + }, + locking_bytecode: true, + transaction_hash: true, + value_satoshis: true, + transaction: { + node_validations: { + validated_at: true, + }, + }, + }, + }, +}; + +/** + * Subscription query to be notified about payments made to a lockscript Hex. + */ +export const monitorTransactionHashSubscriptionQuery = { + subscription: { + __name: 'MonitorTransactionHash', + __variables: { + transactionHash: 'bytea', + }, + transaction: { + __args: { + where: { + node_validations: { + transaction: { + _not: { block_inclusions: {} }, + hash: { _eq: new VariableType('transactionHash') }, + }, + }, + }, + }, + hash: true, + node_validations: { + validated_at: true, + }, + }, + }, +}; + +/** + * Query to fetch the transaction history for a given lockscript Hex. + */ +export const getLockscriptHistoryQuery = { + query: { + __name: 'GetLockscriptHistory', + __variables: { + lockscriptHex: 'bytea', + }, + transaction: { + __args: { + where: { + outputs: { + locking_bytecode: { _eq: new VariableType('lockscriptHex') }, + }, + inputs: { + outpoint: { + locking_bytecode: { _eq: new VariableType('lockscriptHex') }, + }, + }, + }, + }, + hash: true, + is_coinbase: true, + block_inclusions: { + block: { + height: true, + }, + }, + }, + }, +}; + +/** + * Query to send a transaction giving the raw Transaction Hex. + */ +export const sendRawTransactionMutationQuery = { + mutation: { + __name: 'SendRawTransaction', + __variables: { + rawTransactionHex: 'String!', + nodeInternalId: 'bigint!', + }, + send_transaction: { + __args: { + request: { + node_internal_id: new VariableType('nodeInternalId'), + encoded_hex: new VariableType('rawTransactionHex'), + }, + }, + transaction_hash: true, + validation_error_message: true, + validation_success: true, + transmission_error_message: true, + transmission_success: true, + }, + }, +}; + +/** + * Query to fetch latest chain tips. + */ +export const monitorSyncTipsQuery = { + query: { + __name: 'MonitorSyncTips', + __variables: { + chainFilter: 'String = "bchn-mainnet"', // Default to 'bchn-mainnet' + }, + node: { + __args: { + where: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + name: true, + internal_id: true, + user_agent: true, + latest_tip: { + __aliasFor: 'accepted_blocks', + __args: { + limit: 1, + order_by: { block_internal_id: new EnumType('desc') }, + }, + accepted_at: true, + block: { + hash: true, + height: true, + }, + }, + }, + }, +}; + +// Zod Validation rules. + +// Zod common validation rules. +const hashStringZodValidation = z.string().regex(/^\\x.+$/, { + message: "String must start with '\\x' followed by characters", +}); +const numericStringZodValidation = z.string().regex(/^\d+$/, { + message: 'String must contain only digits (0-9)', +}); + +// Response validations for 'MonitorSyncTips' subscription and query. +export const monitorSyncTipsSchema = z.object({ + data: z.object({ + node: z.array( + z.object({ + name: z.enum([ + 'bchn-mainnet', + 'bchn-chipnet', + 'bchn-testnet', + ] as const satisfies Array), + internal_id: z.number(), + user_agent: z.string(), + latest_tip: z.array( + z.object({ + accepted_at: z.string(), + block: z.object({ + hash: hashStringZodValidation, + height: numericStringZodValidation, + }), + }), + ), + }), + ), + }), +}); + +// Response validations for 'search Outputs By LockingBytecode' query. +export const searchOutputsByLockingBytecodeResponseSchema = z.object({ + data: z.object({ + search_output: z.array( + z.object({ + locking_bytecode: hashStringZodValidation, + output_index: numericStringZodValidation, + transaction_hash: hashStringZodValidation, + value_satoshis: numericStringZodValidation, + token_category: z.union([z.null(), hashStringZodValidation]), + fungible_token_amount: z.union([z.null(), numericStringZodValidation]), + // "none", "mutable", or "minting". This field is null if no NFT is present. + nonfungible_token_capability: z.union([ + z.null(), + z.enum(['none', 'mutable', 'minting']), + ]), + nonfungible_token_commitment: z.union([ + z.null(), + hashStringZodValidation, + ]), + transaction: z.object({ + block_inclusions: z.array( + z.object({ + block: z.object({ + height: numericStringZodValidation, + }), + }), + ), + }), + }), + ), + }), +}); + +// Response validations for 'getTransactionHex' query. +export const getTransactionHexResponseSchema = z.object({ + data: z.object({ + transaction: z.array( + z.object({ + encoded_hex: z.string(), + }), + ), + }), +}); + +// Response validations for 'getBlockHeaderHex' query. +export const getBlockHeaderHexResponseSchema = z.object({ + data: z.object({ + block: z.array( + z.object({ + header: z.string(), + }), + ), + }), +}); + +// Response validations for 'monitorForPayments' subscription query. +export const monitorForPaymentsNotificationSchema = z.object({ + data: z.object({ + search_output: z.array( + z.object({ + locking_bytecode: hashStringZodValidation, + transaction_hash: hashStringZodValidation, + value_satoshis: numericStringZodValidation, + transaction: z.object({ + node_validations: z.array( + z.object({ + validated_at: z.string(), + }), + ), + }), + }), + ), + }), +}); + +// Response validations for 'getLockscriptHistory' query. +export const getLockscriptHistoryResponseSchema = z.object({ + data: z.object({ + transaction: z.array( + z.object({ + hash: hashStringZodValidation, + is_coinbase: z.boolean(), + block_inclusions: z.array( + z.object({ + block: z.object({ + height: numericStringZodValidation, + }), + }), + ), + }), + ), + }), +}); + +// Response validations for 'monitorTransactionHash' subscription query. +export const monitorTransactionHashNotificationSchema = z.object({ + data: z.object({ + transaction: z.array( + z.object({ + hash: hashStringZodValidation, + node_validations: z.array( + z.object({ + validated_at: z.string(), + node: z.object({ + name: z.string(), + }), + }), + ), + }), + ), + }), +}); + +// Response validations for 'customSubscription' subscription query. +export const customSubscriptionNotificationSchema = z.object({ + data: z.unknown(), +}); + +// Response validations for 'sendRawTransaction' query. +export const sendRawTransactionMutationResponseSchema = z.object({ + data: z.object({ + send_transaction: z.object({ + transaction_hash: z.string(), + validation_success: z.boolean(), + transmission_success: z.boolean(), + validation_error_message: z.string().optional(), + transmission_error_message: z.string().optional(), + }), + }), +}); diff --git a/packages/stack/src/blockchain/blockchain-chaingraph.ts b/packages/stack/src/blockchain/blockchain-chaingraph.ts new file mode 100644 index 0000000..f2bd81d --- /dev/null +++ b/packages/stack/src/blockchain/blockchain-chaingraph.ts @@ -0,0 +1,979 @@ +import { BaseBlockchain, BlockchainEvents } from './base-blockchain.js'; + +import { + type BlockchainCallback, + type BlockchainTransaction, + type BlockchainUTXO, + type BroadcastOpts, +} from './types.js'; + +import { + Address, + BlockHeader, + ExtMap, + Outpoint, + Transaction, + TransactionHash, + UTXO, +} from '@xocash/primitives'; + +import { type BaseStore, StoreInMemory } from '../storage/index.js'; + +import { hexToBin } from '@bitauth/libauth'; + +import { + type GraphqlRequestResponse, + type GraphqlFetchQuery, + type GraphqlSubscriptionQuery, + type GraphqlQueryVariables, + GraphQLClient, + GraphqlClientEventsPayload, + EventEmitter, +} from '../utils/index.js'; + +import { + // GraphQL queries. + getBlockHeaderHexQuery, + getLockscriptHistoryQuery, + getTransactionHexQuery, + monitorForPaymentsSubscriptionQuery, + monitorSyncTipsQuery, + monitorSyncTipsSubscriptionQuery, + monitorTransactionHashSubscriptionQuery, + searchOutputsByLockingBytecodeQuery, + sendRawTransactionMutationQuery, + // Types. + type ChainNames, + // Zod validation rules. + monitorSyncTipsSchema, + customSubscriptionNotificationSchema, + getBlockHeaderHexResponseSchema, + getLockscriptHistoryResponseSchema, + getTransactionHexResponseSchema, + monitorForPaymentsNotificationSchema, + monitorTransactionHashNotificationSchema, + searchOutputsByLockingBytecodeResponseSchema, + sendRawTransactionMutationResponseSchema, +} from './blockchain-chaingraph-helpers.js'; + +export type BlockchainChaingraphOpts = { + store?: BaseStore; + servers?: Array; + application?: string; + chain?: ChainNames; +}; + +export type ChaingraphLockscriptNotification = { + transactionHash: string; + valueSatoshis: number; +}; + +export type ChaingraphCustomSubscriptionNotification = Record; + +type CustomSubscriptionReturnedObject = { + unsubscribeCallback: () => Promise; + subscriptionResponse?: GraphqlRequestResponse; +}; + +export class BlockchainChaingraph + extends EventEmitter + implements + Omit< + BaseBlockchain< + ChaingraphLockscriptNotification + // TODO: Need to make 'BaseBlockchain' able to notify that is able to return this? + // CustomSubscriptionNotification + >, + 'fetchAddressHistory' + > +{ + graphQLClient!: GraphQLClient; + + // State. + isConnected = false; + blockHeight: number | undefined = undefined; + nodeInternalId: number | undefined = undefined; + + // Lockscripts Subscriptions State. + lockscriptSubscriptions: Map< + string, + Set> + > = new Map(); + + // Transaction Subscription State. + transactionSubscriptions: Map< + string, + Set> + > = new Map(); + + // Custom Subscriptions. + customSubscriptions: Map< + string, + Set> + > = new Map(); + + // Options + store: BaseStore; + servers: Array; + application: string; + chain: ChainNames; + + constructor(public readonly opts: BlockchainChaingraphOpts = {}) { + super(); + + // Set the store, or use the memory store by default. + this.store = opts.store ?? new StoreInMemory(); + + // Combine provided options with default options. + this.servers = opts.servers ?? [ + 'https://demo.chaingraph.cash/v1/graphql', + 'https://gql.chaingraph.pat.mn/v1/graphql', + ]; + this.application = opts.application ?? 'XOApp'; + this.chain = opts.chain ?? 'bchn-mainnet'; + } + + static async from( + this: T, + opts: BlockchainChaingraphOpts = {}, + ) { + const blockchain = new this(opts); + + await blockchain.start(); + + return blockchain as InstanceType; + } + + async start() { + // Iterate over each server in our list of servers. + for (const chaingraphServer of this.servers) { + // Create a new GraphQL client. + const graphQLClient = new GraphQLClient(chaingraphServer); + + // Attempt to connect to this Chaingraph Server. + try { + // Subscribe to be notified about the latest blocks and await for the server to send us data back. + const data = await graphQLClient.subscribe( + 'monitorSyncTips', + monitorSyncTipsSubscriptionQuery, + true, + { chainFilter: this.chain }, + ); + + // Validate the response notification payload. + const syncTips = monitorSyncTipsSchema.parse(data); + + this.isConnected = true; + + this.emit('isConnectedUpdated', this.isConnected); + + // Extracting node data for the chain we are interested into monitor at. + const nodeData = syncTips.data.node.find( + (node) => node.name === this.opts.chain, + ); + + // Update the blockHeight state + // NOTE: Since Chaingraph servers usually are connected to several BCH nodes they return + // the blockheight of each one of them. + // Here we get the max value we found and use it as blockHeight state. + this.blockHeight = nodeData + ? this.getMaxBlockHeight(nodeData.latest_tip) + : 0; + + // Update the node Internal Id state. + this.nodeInternalId = nodeData?.internal_id; + + // If 'blockHeight' state was updated... + if (this.blockHeight) { + // Emit an event to update the blockheight. + this.emit('blockHeightUpdated', this.blockHeight); + } + } catch (error) { + console.warn(error); + + // If it fails to connect, try the next server in our list. + continue; + } + + // Save the graphql client. + this.graphQLClient = graphQLClient; + + // Setup notification handler for connection initialized event. + this.graphQLClient.on( + 'isConnectionInitialized', + (isConnectionInitialized) => { + if (isConnectionInitialized === true) { + this.isConnected = true; + + // Emit an event. + this.emit('isConnectedUpdated', this.isConnected); + + // Return to prevent further code execution. + return; + } + + // Otherwise set the 'isConnected' property to false. + this.isConnected = false; + + // Emit an event. + this.emit('isConnectedUpdated', this.isConnected); + }, + ); + + // Setup notification handler for 'error' event. + this.graphQLClient.on('error', (error) => { + if (error instanceof Error) console.error(error); + }); + + // Setup notification handler for 'monitorSyncTips' event. + this.graphQLClient.on( + 'monitorSyncTips', + this.handleMonitorSyncTips.bind(this), + ); + + // Setup notification handler for 'MonitorForPayments' event. + this.graphQLClient.on( + 'MonitorForPayments', + this.handleLockscriptUpdates.bind(this), + ); + + // Setup notification handler for 'monitorTransactionHash' event. + this.graphQLClient.on( + 'monitorTransactionHash', + this.handleTransactionUpdates.bind(this), + ); + + // Fetch the chain tip. + await this.fetchChainTip(); + + // Return to prevent further execution. + return; + } + + // Throw an error if we failed to connect to any of our Chaingraph Servers. + throw new Error( + 'Failed to connect to Chaingraph. Please try restarting your browser and ensure you are currently connected to the internet.', + ); + } + + async stop() { + // Disconnect from GraphQL Client. + this.graphQLClient.stopAllSubscriptions(); + } + + async fetchUnspents(address: string) { + // Define the cache key. + const cacheKey = `addressUnspents.${address}`; + + // Work out the locking byecode from the address. + const lockingBytecodeHex = Address.from(address).toLockscriptHex(); + + // Attempt to get the address unspents from the cache or fetch them if not available. + const addressUnspents = await this.store.getOrSet(cacheKey, async () => { + const data = await this.graphQLClient.request( + 'SearchOutputsByLockingBytecode', + searchOutputsByLockingBytecodeQuery, + { + chainFilter: this.chain, + lockingBytecodeHexObject: `{ ${lockingBytecodeHex} }`, + }, + ); + + // Validate the response notification payload. + const addressUnspents = + searchOutputsByLockingBytecodeResponseSchema.parse(data); + + return addressUnspents; + }); + + // Work out the locking byecode from the address. + const lockingBytecode = Address.from(address).toLockscriptBytes(); + + // Transform the unspents into our UTXO format. + const utxos: Array = addressUnspents.data.search_output.map( + (unspent) => { + const outpoint = new Outpoint( + hexToBin(unspent.transaction_hash.slice(3)), + Number(unspent.output_index), + ); + + return { + height: this.getMaxBlockHeight(unspent.transaction.block_inclusions), + utxo: UTXO.from(outpoint, { + valueSatoshis: BigInt(unspent.value_satoshis), + token: unspent.token_category + ? { + amount: BigInt(unspent.fungible_token_amount || 0), + category: hexToBin(unspent.token_category.slice(3)), + nft: + unspent.nonfungible_token_capability && + unspent.nonfungible_token_commitment + ? { + capability: unspent.nonfungible_token_capability, + commitment: hexToBin( + unspent.nonfungible_token_commitment.slice(3), + ), + } + : undefined, + } + : undefined, + lockingBytecode, + }), + }; + }, + ); + + // Index the UTXOs by hashInHex:index. + const utxosIndexed = ExtMap.fromArray(utxos, (blockchainUTXO) => { + return blockchainUTXO.utxo.outpoint.toString(); + }); + + // Return the unspents. + return utxosIndexed; + } + + async fetchTransaction(txHash: string) { + // Define the cache key. + const cacheKey = `transactions.${txHash}`; + + // Attempt to get the transaction hex from the cache or fetch it if not available. + const transactionRaw = await this.store.getOrSet(cacheKey, async () => { + const data = await this.graphQLClient.request( + 'GetTransactionHexQuery', + getTransactionHexQuery, + { + chainFilter: this.chain, + txHash: `\\x${txHash}`, + }, + ); + + // Validate the response notification payload. + const txHex = getTransactionHexResponseSchema.parse(data); + + if (!txHex.data.transaction.length) { + throw new Error(`Tx "${txHash}" does not exist`); + } + + // Chaingraph returns a list of transactions... + // NOTE: CHaingraph can return a list from different nodes, we take the first + return Transaction.fromHex(txHex.data.transaction[0].encoded_hex).toRaw(); + }); + + // Decode the raw data into our transaction primitive. + const transaction = Transaction.fromRaw(transactionRaw); + + // Return the transaction hex. + return transaction; + } + + // NOTE: We are not currently enabling this function as usable due to errors in the chaingprah backend. + // TODO: Currently this approach leverages the 'fetchTransaction' function of this class to get the 'encoded_hex' from each transaction of the history for a given address. + // The drawback of this is that we are not using the full potential of chaingraph where we could being fetching this info straigh from the service. + // But this needs to be done also by looking what we have in our store not to make unnecessary calls. + async fetchAddressHistory(address: string) { + // Define the cache key. + const cacheKey = `addressHistory.${address}`; + + // Attempt to get the address history from the cache or fetch them if not available. + const addressHistory = await this.store.getOrSet(cacheKey, async () => { + const data = await this.graphQLClient.request( + 'GetLockscriptHistory', + getLockscriptHistoryQuery, + { + lockscriptHex: `\\x${Address.fromCashAddr(address).toLockscriptHex()}`, + }, + ); + + // Validate the response notification payload. + const lockscriptHistory = getLockscriptHistoryResponseSchema.parse(data); + + return lockscriptHistory; + }); + + // Get the transactions for each history item. + const transactions: Array = await Promise.all( + addressHistory.data.transaction.map(async (item) => ({ + hash: TransactionHash.fromHex(item.hash), + height: this.getMaxBlockHeight(item.block_inclusions), + transaction: await this.fetchTransaction(item.hash), + })), + ); + + // Index the transactions by their hash (in hex). + const transactionsIndexed = ExtMap.fromArray(transactions, (tx) => + tx.hash.toHex(), + ); + + // Return the transactions. + return transactionsIndexed; + } + + async fetchBlockHeader(blockHeight: number) { + // Define the cache key. + const cacheKey = `blockHeader.${blockHeight}`; + + // Attempt to get the block header from the cache or fetch them if not available. + const blockHeaderRaw = await this.store.getOrSet(cacheKey, async () => { + const data = await this.graphQLClient.request( + 'GetBlockHeaderHex', + getBlockHeaderHexQuery, + { + chainFilter: this.chain, + blockHeight, + }, + ); + + // Validate the response notification payload. + const headerHex = getBlockHeaderHexResponseSchema.parse(data); + + if (!headerHex.data.block.length) { + throw new Error(`Block with height "${blockHeight}" doesn't exist`); + } + + // Chaingraph returns a list of transactions... + // NOTE: CHaingraph can return a list from different nodes, we take the first + return BlockHeader.fromHex(headerHex.data.block[0].header).toRaw(); + }); + + // Decode the raw data into our Block Header primitive. + const blockHeader = BlockHeader.fromRaw(blockHeaderRaw); + + // Return the block header. + return blockHeader; + } + + async fetchChainTip() { + // If we don't currently have a block height, fetch the current chain tip. + // NOTE: We don't use our cache here because the key will be the same and so on-boot, we will have a stale value. + if (!this.blockHeight) { + // Request chaingraph about the latest block. + const data = await this.graphQLClient.request( + 'monitorSyncTipsQuery', + monitorSyncTipsQuery, + { chainFilter: this.chain }, + ); + + // Validate the response notification payload. + const chainTips = monitorSyncTipsSchema.parse(data); + + // Extracting node data for the chain we are interested into monitor at. + const nodeData = chainTips.data.node.find( + (node) => node.name === this.opts.chain, + ); + + // Update the blockHeight state + // NOTE: Since Chaingraph servers usually are connected to several BCH nodes they return + // the blockheight of each one of them. + // Here we get the max value we found and use it as blockHeight state. + this.blockHeight = nodeData + ? this.getMaxBlockHeight(nodeData.latest_tip) + : 0; + } + + // Get the block header + // NOTE: If we didn't have the block height, we are performing double-fetch here + const blockHeader = await this.fetchBlockHeader(this.blockHeight); + + // Create a chain tip object. + const chainTip = { + height: this.blockHeight, + blockHeader: blockHeader, + }; + + // Return the chain tip. + return chainTip; + } + + async broadcastTransaction( + tx: Transaction | Uint8Array | string, + opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true }, + ) { + if (!this.nodeInternalId) + throw new Error( + 'There is no operative node registered. This error is probably due to the service not being started.', + ); + + // Cast the transaction. + if (!(tx instanceof Transaction)) { + tx = Transaction.from(tx); + } + + // Create a promise that will resolve when transaction is seen. + const waitForTxPromise = this.waitForTransaction(tx, opts.timeoutMs); + + // Broadcast the transaction. + const data = await this.graphQLClient.request( + 'SendRawTransactionMutation', + sendRawTransactionMutationQuery, + { + rawTransactionHex: tx.toHex(), + nodeInternalId: this.nodeInternalId, + }, + ); + + // Validate the response notification payload. + const txBroadcastResponse = + sendRawTransactionMutationResponseSchema.parse(data); + + if ( + !txBroadcastResponse.data.send_transaction.validation_success || + !txBroadcastResponse.data.send_transaction.transmission_success + ) + throw new Error( + `Unable to broadcast the transaction. Detailed error: ${txBroadcastResponse.data.send_transaction.validation_error_message || txBroadcastResponse.data.send_transaction.transmission_error_message}`, + ); + + // Wait for the transaction to be seen. + await waitForTxPromise; + } + + // NOTE: To investigate further, there is a way to subscribe to multiple 'lockscript' using just 1 chaingraph subscription query. + // Currently this approach is running 1 subscription for every lockscript, which is probably not very optimal. + // The drawback with the approach that subscribe to multiple 'lockscript' in 1 subscription query is that would require + // unsubscribe first in order to reuse the 'queryId' which would lead to miss notifications in the time it takes + // to the new subscription to setup. + async subscribeLockscript( + lockscript: string | Uint8Array, + callback: BlockchainCallback, + ) { + // Parsing to lockscript hex. + const lockscriptHex = Address.fromLockscript(lockscript).toLockscriptHex(); + + // Get or create the set of callbacks for every lockscript. + let callbacks = this.lockscriptSubscriptions.get(lockscriptHex); + if (!callbacks) { + callbacks = new Set(); + this.lockscriptSubscriptions.set(lockscriptHex, callbacks); + + // Execute the subscription if there are no callbacks meaning is the first time we run this. + await this.graphQLClient.subscribe( + // Use a queryId with a minus(`-`) separator in it to indicate that we want also receive notifications for 'MonitorForPayments' shortcut. + `MonitorForPayments-${lockscriptHex}`, + monitorForPaymentsSubscriptionQuery, + false, + { + lockscriptHexObject: `{ ${lockscriptHex} }`, + }, + ); + } + + // Add the callback to the set + callbacks.add(callback); + + // Return an unsubscribe callback. + return async () => + await this.unsubscribeLockscript(lockscriptHex, callback); + } + + async unsubscribeLockscript( + lockscript: string | Uint8Array, + callback: BlockchainCallback, + ): Promise { + // Parsing to lockscript hex. + const lockscriptHex = Address.fromLockscript(lockscript).toLockscriptHex(); + + // Get the callbacks set for this address. + const callbacks = this.lockscriptSubscriptions.get(lockscriptHex); + if (!callbacks) { + return false; + } + + // Find the index of the callback in the handlers array + if (!callbacks.has(callback)) { + return false; + } + + // Remove the specific callback + callbacks.delete(callback); + + // If there are no more callbacks, remove the lockscript entry + // and potentially unsubscribe from blockchain updates. + if (callbacks.size === 0) { + this.lockscriptSubscriptions.delete(lockscriptHex); + + await this.graphQLClient.unsubscribe([ + `MonitorForPayments-${lockscriptHex}`, + ]); + } + + return true; + } + + async subscribeAddress( + address: string, + callback: BlockchainCallback, + ) { + // Parsing to lockscript hex. + const lockscript = Address.fromCashAddr(address).toLockscriptHex(); + + // Use our subscribeLockscript function. + return await this.subscribeLockscript(lockscript, callback); + } + + async unsubscribeAddress( + address: string, + callback: BlockchainCallback, + ) { + // Parsing to lockscript hex. + const lockscript = Address.fromCashAddr(address).toLockscriptHex(); + + // Use our unsubscribeAddress function. + return await this.unsubscribeLockscript(lockscript, callback); + } + + async subscribeTransaction( + transactionHash: string, + callback: BlockchainCallback, + ) { + // Get or create the set of callbacks for this transaction. + let callbacks = this.transactionSubscriptions.get(transactionHash); + if (!callbacks) { + callbacks = new Set(); + this.transactionSubscriptions.set(transactionHash, callbacks); + + // Execute the subscription if there are no callbacks meaning is the first time we run this. + await this.graphQLClient.subscribe( + // Use a queryId with a minus(`-`) separator in it to indicate that we want also receive notifications for 'monitorTransactionHash' shortcut. + `monitorTransactionHash-${transactionHash}`, + monitorTransactionHashSubscriptionQuery, + false, + { + transactionHash: `\\x${transactionHash}`, + }, + ); + } + + // Add the callback to the set + callbacks.add(callback); + + // Return an unsubscribe callback. + return async () => this.unsubscribeTransaction(transactionHash, callback); + } + + async unsubscribeTransaction( + transactionHash: string, + callback: BlockchainCallback, + ) { + // Get the callbacks set for this transaction + const callbacks = this.transactionSubscriptions.get(transactionHash); + if (!callbacks) { + return false; + } + + if (!callbacks.has(callback)) { + return false; + } + + // Remove the specific callback + callbacks.delete(callback); + + // If there are no more callbacks, remove the transaction entry + // and potentially unsubscribe from blockchain updates + if (callbacks.size === 0) { + this.transactionSubscriptions.delete(transactionHash); + + await this.graphQLClient.unsubscribe([ + `monitorTransactionHash-${transactionHash}`, + ]); + } + + return true; + } + + /** + * NOTE: 'query' param needs to be a JS obejct written using the 'json-to-graphql-query' library format. + * Check out: https://www.npmjs.com/package/json-to-graphql-query + */ + async request( + requestId: string, + query: GraphqlFetchQuery, + variables?: GraphqlQueryVariables, + ): Promise { + return await this.graphQLClient.request(requestId, query, variables); + } + + /** + * NOTE: 'query' param needs to be a JS obejct written using the 'json-to-graphql-query' library format. + * Check out: https://www.npmjs.com/package/json-to-graphql-query + */ + async subscribe( + subscription: { + subscriptionId: string; + query: GraphqlSubscriptionQuery; + waitForResponse: boolean; + variables?: GraphqlQueryVariables; + }, + callback: BlockchainCallback, + ): Promise { + // Define a notification handler for the subscription. + const notificationHandler = (notification: GraphqlClientEventsPayload) => { + // Validate the response notification payload. + const customSubscriptionNotification = + customSubscriptionNotificationSchema.parse(notification); + + // Get the callbacks for this subscription. + const callbacks = this.customSubscriptions.get( + subscription.subscriptionId, + ); + if (!callbacks) { + return; + } + + // Execute all callbacks for this subscription. + for (const callback of callbacks) { + try { + callback(subscription.subscriptionId, customSubscriptionNotification); + } catch (error) { + // Handle or log any callback execution errors + console.error( + `Error executing callback for subscription ${subscription.subscriptionId}: `, + error, + ); + } + } + }; + + // Define an unsubscription callback. + const unsubscribeCallback = async () => { + // Get the callbacks set for this transaction + const callbacks = this.customSubscriptions.get( + subscription.subscriptionId, + ); + if (!callbacks) { + return false; + } + + if (!callbacks.has(callback)) { + return false; + } + + // Remove the specific callback + callbacks.delete(callback); + + // If there are no more callbacks, remove the custom subscription entry + // and potentially unsubscribe from blockchain updates + if (callbacks.size === 0) { + this.customSubscriptions.delete(subscription.subscriptionId); + + await this.graphQLClient.unsubscribe([subscription.subscriptionId]); + + // Deattach the 'notificationHandler' callback from the GraphQL Client events emiter. + this.graphQLClient.off( + subscription.subscriptionId, + notificationHandler, + ); + } + + return true; + }; + + // Define an object to return. + const objectToReturn: CustomSubscriptionReturnedObject = { + unsubscribeCallback, + }; + + // Get or create the set of callbacks for this subscription. + let callbacks = this.customSubscriptions.get(subscription.subscriptionId); + // If there are not callbacks for this 'subscriptionId' that means this subscription has been never ran before. + if (!callbacks) { + callbacks = new Set(); + this.customSubscriptions.set(subscription.subscriptionId, callbacks); + + // Setup a notification handler for this 'subscriptionId' event. + this.graphQLClient.on( + subscription.subscriptionId, + notificationHandler.bind(this), + ); + + // Run the subscription and catch the returned value in response if any. + const response = (await this.graphQLClient.subscribe( + subscription.subscriptionId, + subscription.query, + subscription.waitForResponse, + subscription.variables, + )) as GraphqlRequestResponse; + + // Include response in the object to return if any. + if (response) objectToReturn['subscriptionResponse'] = response; + } + + // Add the callback to the set. + callbacks.add(callback); + + return objectToReturn; + } + + protected handleMonitorSyncTips(notification: GraphqlClientEventsPayload) { + // Validate the response notification payload. + const monitorSyncTips = monitorSyncTipsSchema.parse(notification); + + // Extract node data of the chain we are working with. + const nodeData = monitorSyncTips.data.node.find( + (node) => node.name === this.opts.chain, + ); + + // Assign the block height. + this.blockHeight = nodeData + ? this.getMaxBlockHeight(nodeData.latest_tip) + : 0; + + if (this.blockHeight) { + // Emit an event to update the blockheight. + this.emit('blockHeightUpdated', this.blockHeight); + } + } + + protected async handleLockscriptUpdates( + notification: GraphqlClientEventsPayload, + ) { + // Validate the response notification payload. + const monitorForPayments = + monitorForPaymentsNotificationSchema.parse(notification); + + const updates = monitorForPayments.data.search_output; + + for (const update of updates) { + const cashAddress = Address.fromLockscriptHex( + update.locking_bytecode.slice(3), + ).toCashAddr(); + + // Clear our cache for this address. + await this.store.delete(`addressUnspents.${cashAddress}`); + await this.store.delete(`addressHistory.${cashAddress}`); + + // Get the callbacks for this transaction + const callbacks = this.lockscriptSubscriptions.get(cashAddress); + if (!callbacks) { + return; + } + + // Execute all callbacks for this transaction. + for (const callback of callbacks) { + try { + callback(cashAddress, { + transactionHash: update.transaction_hash, + valueSatoshis: Number(update.value_satoshis), + }); + } catch (error) { + // Handle or log any callback execution errors + console.error( + `Error executing callback for address ${cashAddress}:`, + error, + ); + } + } + } + } + + protected handleTransactionUpdates(notification: GraphqlClientEventsPayload) { + // Validate the response notification payload. + const monitorForTransactions = + monitorTransactionHashNotificationSchema.parse(notification); + + const updates = monitorForTransactions.data.transaction; + + for (const update of updates) { + // Get the callbacks for this transaction + const callbacks = this.transactionSubscriptions.get(update.hash); + if (!callbacks) { + return; + } + + // Execute all callbacks for this transaction. + for (const callback of callbacks) { + try { + callback(update.hash); + } catch (error) { + // Handle or log any callback execution errors + console.error( + `Error executing callback for transaction ${update.hash}:`, + error, + ); + } + } + } + } + + private getMaxBlockHeight( + blocks: Array<{ + // Allows any other properties together with 'block' + [key: string]: unknown; + block: { + [key: string]: unknown; + height: string; + }; + }>, + ): number { + return Math.max(0, ...blocks.map((item) => Number(item.block.height))); + } + + /** + * NOTE: Follow functions are cloned from 'BaseBlockchain' class + * TODO: Delete these when we not need to anymore the implementation of 'fetchAddressHistory' + * due to chaingraph backend issues. + */ + /** + * Waits for a transaction to be seen by the node. + * + * @param tx - The transaction to wait for. Can be a Uint8Array, Transaction instance, or a string. + * @param timeoutMs - The maximum time to wait in milliseconds before timing out. Defaults to 10,000ms (10 seconds). + * @returns A Promise that resolves when the transaction is seen by the node or rejects if the timeout is reached. + * @throws Error if the transaction is not seen by the node within the specified timeout. + */ + async waitForTransaction( + tx: Uint8Array | Transaction | string, + timeoutMs = 10_000, + ) { + // Cast the transaction. + if (!(tx instanceof Transaction)) { + tx = Transaction.from(tx); + } + + // Then wait for either resolution or timeout + return this.waitForTransactionHash(tx.toHash(), timeoutMs); + } + + async waitForTransactionHash( + txHash: TransactionHash | Uint8Array | string, + timeoutMs = 10_000, + ) { + // Cast the transaction hash. + if (!(txHash instanceof TransactionHash)) { + txHash = TransactionHash.from(txHash); + } + + // First, subscribe to the transaction + let unsubscribeCallback: () => void; + const subscriptionPromise = this.subscribeTransaction( + txHash.toHex(), + () => { + if (unsubscribeCallback) { + unsubscribeCallback(); + } + }, + ); + + // Then create the wait promise without an async executor + const waitForTxPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (unsubscribeCallback) { + unsubscribeCallback(); + } + reject(new Error(`Failed to broadcast ${txHash}`)); + }, timeoutMs); + + // Create a subscribe function so that we get notified when transaction is seen by network + unsubscribeCallback = () => { + clearTimeout(timeout); + this.unsubscribeTransaction(txHash.toHex(), unsubscribeCallback); + resolve(); + }; + }); + + // Wait for subscription to be set up first + await subscriptionPromise; + + // Then wait for either resolution or timeout + return waitForTxPromise; + } +} diff --git a/packages/stack/src/blockchain/blockchain-electrum.ts b/packages/stack/src/blockchain/blockchain-electrum.ts index 9e3eeee..196560c 100644 --- a/packages/stack/src/blockchain/blockchain-electrum.ts +++ b/packages/stack/src/blockchain/blockchain-electrum.ts @@ -5,6 +5,7 @@ import { type BlockchainCallback, type BlockchainTransaction, type BlockchainUTXO, + type BroadcastOpts, } from './types.js'; import { type BaseStore, StoreInMemory } from '../storage/index.js'; @@ -37,11 +38,6 @@ import { import { hexToBin } from '@bitauth/libauth'; -export interface BroadcastOpts { - timeoutMs: number; - waitUntilSeen: boolean; -} - export type BlockchainElectrumEvents = { isConnectedUpdated: boolean; blockHeightUpdated: number | undefined; diff --git a/packages/stack/src/blockchain/types.ts b/packages/stack/src/blockchain/types.ts index 1126280..6c14839 100644 --- a/packages/stack/src/blockchain/types.ts +++ b/packages/stack/src/blockchain/types.ts @@ -54,3 +54,9 @@ export type BlockchainChainTip = { height: number; blockHeader: BlockHeader; }; + +// Broadcast Transaction Options used in blockchain bridge classes. +export interface BroadcastOpts { + timeoutMs: number; + waitUntilSeen: boolean; +} diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 9ec9247..b4e639c 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -13,6 +13,7 @@ export * from './wallet/index.js'; // NOTE: For browser-only providers, export these from index.browser.ts. // For node-only providers, export these from index.node.ts. export * from './blockchain/blockchain-electrum.js'; +export * from './blockchain/blockchain-chaingraph.js'; export * from './storage/store-remote-http.js'; export * from './rates/rates-bitpay.js'; export * from './rates/rates-coinbase.js'; diff --git a/packages/stack/src/utils/graphql-client.ts b/packages/stack/src/utils/graphql-client.ts new file mode 100644 index 0000000..8c5533d --- /dev/null +++ b/packages/stack/src/utils/graphql-client.ts @@ -0,0 +1,465 @@ +import { WebsocketClient } from './ws-client.js'; +import { EventEmitter } from './event-emitter.js'; + +import { jsonToGraphQLQuery } from 'json-to-graphql-query'; +import { z } from 'zod'; + +// The format of the querys accepted by 'request' and 'subscribe' functions. +export type GraphqlFetchQuery = + | Record<'query', unknown> + | Record<'mutation', unknown>; +export type GraphqlSubscriptionQuery = Record<'subscription', unknown>; + +// Format for query variables. +export type GraphqlQueryVariables = Record; + +// Shape of the Connection Initialization Message chaingraph accepts. +type ConnectionInitMessage = { + type: 'connection_init'; + // 'Record' stand for empty payload. + payload: Record | Record<'token', string>; +}; + +// Shape of the data query used for Requests and Subscriptions. +type DataRequestMessage = { + id: string; + type: 'start'; + payload: { + queryId: string; + query: string; + variables?: GraphqlQueryVariables; + }; +}; + +// Shape of the unsubscription query. +type UnsubscriptionMessage = { + id: string; + type: 'stop'; +}; + +// Shape of a chaingraph response. +export type GraphqlRequestResponse = { + data: Record; +}; + +// Shape of the subscription objects stored in our Map of active subscriptions. +type Subscriptions = { + queryId: string; + + body: { + type: DataRequestMessage['type']; + + payload: { + query: GraphqlSubscriptionQuery; + variables?: GraphqlQueryVariables; + }; + }; +}; + +export type GraphqlClientEventsPayload = + | Record + | Error + | boolean; + +export type GraphQLClientEvents = { + /** + * Emitted when data has been received from a subscription via websocket. + * Emitted when the socket has failed in some way. + * + * List of events emitted: + * 'isConnectionInitialized' : boolean emitted when graphql protocol handshake was accepted and websocket messaging started. + * 'error' : Error emitted when the websocket or chaingraph notify about an error. + * '' : Record emitted when server send a message that carries updated data of a subscription made. + * + * @eventProperty + */ + [K in T]: GraphqlClientEventsPayload; +}; + +export class GraphQLClient extends EventEmitter { + // Our websocket client implementation that will handle subscriptions. + private websocket: WebsocketClient; + + // Track if the websocket connection has been initialized and handshake made. + private isWebSocketConnectionInitialized: boolean; + + // Map that contain relative data to currently active subscriptions. + // This will allow us to re-subscribe in case of websocket disconnetion. + private activeSubscriptions: Map< + Subscriptions['queryId'], + Subscriptions['body'] + > = new Map(); + + constructor( + public endpoint: string, + private token?: string, + ) { + super(); + + this.isWebSocketConnectionInitialized = false; + + // Defines the subprotocol to comunicate with a graphql server. + const options = { + protocols: 'graphql-ws', + }; + + // Adapt the given endpoint to setup a websocket comunication protocol. + const websocketHost = this.endpoint.includes('http') + ? this.endpoint.replace('http', 'ws') + : this.endpoint.includes('https') + ? this.endpoint.replace('https', 'wss') + : this.endpoint; + + // Instantiate our WebSocket Client class. + this.websocket = new WebsocketClient(websocketHost, options); + + // Subscribe to events emited by our websocket client. + this.websocket.on('isOpen', (isOpen: boolean) => { + // If WebSocket is open... + if (isOpen) { + this.handleOpenSocket.bind(this); + + // return to prevent further code execution. + return; + } + + // Otherwise update internal status to disconnected and emit. + this.isWebSocketConnectionInitialized = false; + + this.emit( + 'isConnectionInitialized', + this.isWebSocketConnectionInitialized, + ); + }); + this.websocket.on('message', this.handleMessageResponse.bind(this)); + this.websocket.on('error', (error) => { + this.emit('error', new Error(`WebSocket error: ${error}`)); + }); + } + + private async handleOpenSocket(): Promise { + // Re-subscribe to the currently active subscriptions. + if (this.activeSubscriptions.size > 0) { + // Create a list that will carry the subscriptions to be made. + const subscriptionPromises = []; + + for (const key of this.activeSubscriptions.keys()) { + const subscriptionBody = this.activeSubscriptions.get(key); + + if (subscriptionBody) + subscriptionPromises.push( + async () => + await this.subscribe( + key, + subscriptionBody.payload.query, + false, + subscriptionBody.payload.variables, + ), + ); + } + + // Request the server to run all the subscriptions in parallel. + await Promise.all(subscriptionPromises); + } + } + + /** + * Parse and handle websocket subscription messages done by the graphl server. + */ + private handleMessageResponse(event: MessageEvent): void { + // Validate the payload body. + const eventData = GraphQLClient.subscriptionResponseSchema.parse( + JSON.parse(event.data), + ); + + // If Graphql server notifies of error trying to establish connection. + if (eventData.type === 'connection_error') { + this.isWebSocketConnectionInitialized = false; + + this.emit( + 'error', + new Error( + `Error of connection. Code: ${eventData.payload.code}, Message: ${eventData.payload.message}`, + ), + ); + + // Return to avoid further code execution. + return; + } + + // If Graphql server notifies of successfully connection initialized. + if (eventData.type === 'connection_ack') { + this.isWebSocketConnectionInitialized = true; + + this.emit( + 'isConnectionInitialized', + this.isWebSocketConnectionInitialized, + ); + + // Return to avoid further code execution. + return; + } + + // If Graphql server notifies with a data message for a given subscription. + if (eventData.type === 'data') { + // Check if the payload contain error messages and propagate properly. + if (eventData.payload?.errors) { + const errorsMessage = ( + eventData.payload.errors as Array<{ message: string }> + ) + .map((item) => item.message) + .join(); + this.emit('error', new Error(errorsMessage)); + + // Return to avoid further code execution. + return; + } + + // Otherwise just emit the data with the subscription id. + this.emit(eventData.id, eventData.payload as GraphqlRequestResponse); + + // Try to split the 'queryId' by `-`. + // This indicates that user wants the subscription to emit twice. + // Once for the full queryId and once for the part before the `-` separator. + const queryId = eventData.id.split('-'); + // If 'queryId' contained a separator `-` symbol... + if (queryId.length > 0) + this.emit(queryId[0], eventData.payload as GraphqlRequestResponse); + } + // If payload type is 'error' propagate it properly. + else if (eventData.type === 'error') { + this.emit('error', new Error(eventData.payload.message as string)); + } + } + + public stopAllSubscriptions(): void { + this.websocket.stop(); + + this.activeSubscriptions.clear(); + } + + /** + * Execute a GraphQL query. + * + * @param {string} queryId The query id or name. + * @param {string} query The query object. + * @param {string} variables An object containing the query variables. + * + * @returns {Promise} A promise that resolves with data for the given query if successfull. + * @throws {Error} If the server encounter an error trying to run the query. + * @throws {Error} If the fetch request didn't resolves successfully. + */ + async request( + queryId: string, + query: GraphqlFetchQuery, + variables?: GraphqlQueryVariables, + ): Promise { + try { + // Build the graphql request. + const body: DataRequestMessage['payload'] = { + queryId, + query: jsonToGraphQLQuery(query), + variables: variables ? variables : {}, + }; + + console.log(body); + + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`HTTP fetch error with status: ${response.status}`); + } + + // Validate the response payload. + const result = GraphQLClient.requestResponseSchema.parse( + await response.json(), + ); + + if ('errors' in result) { + const errorsString = result.errors + .map((error) => error.message) + .join(', '); + + throw new Error( + `GraphQL request failed. Detailed errors: ${errorsString}`, + ); + } + + return result; + } catch (error: unknown) { + throw new Error(`GraphQL request failed. Detailed error: ${error}`); + } + } + + /** + * Execute a GraphQL subscription. + * + * NOTE: If you use a queryId that contains a minus(`-`) char in its name (e.g. MonitorForPayments-0xaqwe), + * This will emit twice, once for the full queryId and once for the first part before the `-` separator. + * This is usefull if you want to setup notification handlers that are standard for several subscriptions. + * + * @param {string} queryId The query id or name. Must be unique to avoid conflicts with other active subscriptions. + * @param {string} query The query object. + * @param {string} waitForResponse Indicates if the subscription must await for response data to return. + * @param {string} variables An object containing the query variables. + * + * @returns {Promise} A promise that resolves with data for the given query if successfull. + * @returns {Promise} Returns void if 'waitForResponse' is false. + * @throws {Error} If the server encounter an error trying to run the subscription. + * @throws {Error} If the websocket connection is unable to resolve the data exchange successfully. + * @throws {Error} If a subscription with the same queryId has been already send. + */ + async subscribe( + queryId: string, + query: GraphqlSubscriptionQuery, + waitForResponse: boolean, + variables?: GraphqlQueryVariables, + ): Promise { + // Check if there is an active subscription with the same id. + if (this.activeSubscriptions.has(queryId)) + throw new Error(`Subscription with id (${queryId} is already in use.)`); + + // A first shakehand with a chaingraph server its necessary before any subscription. + if (!this.isWebSocketConnectionInitialized) + await this.initializeWebsocketConnection(); + + // Build the graphql subscription. + const subscription: DataRequestMessage = { + id: queryId, + type: 'start', + payload: { + queryId: queryId, + query: jsonToGraphQLQuery(query), + variables: variables ? variables : {}, + }, + }; + + await this.websocket.send(JSON.stringify(subscription)); + + // If 'waitForResponse' was set to 'true' we proceed to await for the server to return data as if were a normal fetch. + // This function will not return until that point. + if (waitForResponse) { + let dataOrError: GraphqlRequestResponse | Error; + + // After subscribe await for the server to send us back a message with the id of the subscription we just execute. + await this.waitFor(queryId, (event) => { + // If response is instance of boolean, keep waiting. + if (event instanceof Boolean) return false; + + // Since a parsed server message can only be any of these {} 3 types, + // We assert that if not boolean the response is a 'Record' of data or an 'Error' and proceed to finallize the await. + dataOrError = event as GraphqlRequestResponse | Error; + + return true; + }); + + // If server response is instance of error... + if (dataOrError! instanceof Error) + throw new Error( + `Error trying to subscribe to ${queryId}. Detailed info: ${dataOrError.message}`, + ); + + // Otherwise if server response is a 'Record' of valuable data we proceed to store the query as an active subscription. + this.activeSubscriptions.set(queryId, { + type: 'start', + payload: { query, variables: variables || {} }, + }); + + // Since we are waiting for a server response we grant here that 'dataOrError' variable is not undefined. + return dataOrError!; + } + + // Stay waiting asynchronously for a valid server response before add this query to our 'activeSubscriptions' list. + this.waitFor(queryId, (event) => { + if (!(event instanceof Error)) + this.activeSubscriptions.set(queryId, { + type: 'start', + payload: { query, variables: variables || {} }, + }); + + return true; + }); + } + + /** + * Stop a given list of subscriptions from server. + * + * @param {Array} queryIds A list of queryId. + */ + async unsubscribe(queryIds: Array): Promise { + const unsubscribePromises = []; + + for (const id of queryIds) { + // Check if the query 'id' is on our list of 'activeSubscriptions'. + // If is not, it means that is not an active subscription and there is nothing to unsubscribe. + if (!this.activeSubscriptions.has(id)) continue; + + // Build the graphql unsubscription query. + const unsubscriptionSignal: UnsubscriptionMessage = { + id, + type: 'stop', + }; + + unsubscribePromises.push(async (): Promise => { + await this.websocket.send(JSON.stringify(unsubscriptionSignal)); + + this.activeSubscriptions.delete(id); + }); + } + + // Run all the unsubscriptions in parallel and wait all of them resolve or reject. + await Promise.allSettled(unsubscribePromises); + + // If there is no more 'activeSubscriptions' close the WebSocket connection to save bandwith. + if (this.activeSubscriptions.size === 0) this.websocket.stop(); + } + + private async initializeWebsocketConnection(): Promise { + // Build the graphql connection_init query. + const connectionInitSignal: ConnectionInitMessage = { + type: 'connection_init', + payload: this.token ? { token: this.token } : {}, + }; + + await this.websocket.send(JSON.stringify(connectionInitSignal)); + + // Wait for the handshake to be accepted by the server. + await this.waitFor( + 'isConnectionInitialized', + (isConnectionInitialized) => isConnectionInitialized === true, + ); + } + + // Validations for our subscription response body. + public static subscriptionResponseSchema = z.union([ + z.object({ + id: z.string(), + type: z.enum(['connection_error', 'data', 'error']), + payload: z.record( + z.string(), + z.union([ + z.record(z.string(), z.unknown()), + z.string(), + z.array(z.unknown()), + ]), + ), + }), + z.object({ + type: z.enum(['connection_ack', 'ka']), + }), + ]); + + // Validations for our fetch response body. + public static requestResponseSchema = z.union([ + z.object({ + data: z.record(z.string(), z.unknown()), + }), + z.object({ + errors: z.array(z.object({ message: z.string() })), + }), + ]); +} diff --git a/packages/stack/src/utils/index.ts b/packages/stack/src/utils/index.ts index e506e78..e30cc9b 100644 --- a/packages/stack/src/utils/index.ts +++ b/packages/stack/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './event-emitter.js'; export * from './exponential-backoff.js'; export * from './ext-error.js'; +export * from './graphql-client.js'; export * from './inverse-bloom-filter.js'; export * from './memoizer.js'; export * from './misc.js'; @@ -8,3 +9,4 @@ export * from './sse-session.js'; export * from './sse-session-msgpackr.js'; export * from './type-utils.js'; export * from './uuid.js'; +export * from './ws-client.js'; diff --git a/packages/stack/src/utils/memoizer.ts b/packages/stack/src/utils/memoizer.ts index c6c8e19..818d16d 100644 --- a/packages/stack/src/utils/memoizer.ts +++ b/packages/stack/src/utils/memoizer.ts @@ -3,7 +3,7 @@ export type MemoizerItem = { value: T; timestamp: number; -} +}; export class Memoizer { private cache = new Map>(); @@ -32,7 +32,7 @@ export class Memoizer { if (ttl) { this.removeIfExpired(key, ttl); } - + if (!this.cache.has(key)) { this.set(key, compute()); } diff --git a/packages/stack/src/utils/ws-client.ts b/packages/stack/src/utils/ws-client.ts new file mode 100644 index 0000000..aacab4d --- /dev/null +++ b/packages/stack/src/utils/ws-client.ts @@ -0,0 +1,250 @@ +import { EventEmitter } from './event-emitter.js'; +import { ExponentialBackoff } from './exponential-backoff.js'; + +/** + * List of events emitted by the WebSocket. + * @event + * @ignore + */ +export type WebSocketEvents = { + /** + * Emitted when data has been received. + * @eventProperty + */ + message: MessageEvent; + + /** + * Emitted when socket connects. + * @eventProperty + */ + isOpen: boolean; + + /** + * Emitted when the socket has failed in some way. + * @eventProperty + */ + error: Error; +}; + +/** + * Defines the accepted options by the WebSocket Client. + */ +type Options = { + /** + * Specifies the server port upon the WebSocket Client is going to be connected. + */ + port?: number; + + /** + * Specifies the subprotocols(messaging structures) necessary to comunicate with the server side. + */ + protocols?: Array | string; + + /** + * Defines the amount of reconnection attempts before throwing failure. + */ + reconnectionAttempts?: number; +}; + +/** + * A custom Websocket Client implementation. + */ +export class WebsocketClient extends EventEmitter { + // The websocket connection. + private socket: WebSocket | undefined; + + // The exponential backoff handler to re-attempt in case of connection failures. + private exponentialBackoff: ExponentialBackoff; + + // Track if service is started or not by the user. + private isServiceStarted: boolean; + + // Track if there is an open and working connection to the server. + public isConnected: boolean; + + constructor( + public readonly host: string, + public readonly options?: Options, + ) { + super(); + + this.exponentialBackoff = ExponentialBackoff.from({ + // NOTE: Here the max delay between attempts is not an arbitray number, + // is an average waiting time that will work for even commonly appalling internet connections. + maxDelay: 15000, + // NOTE: We dont want a lot of forced reconnection attemps by default unless is the desired behavior the user wants, + // 3 attempts by default is an small enough window time frame to not cause any long waiting time. + maxAttempts: this.options?.reconnectionAttempts || 3, + growthRate: 1.3, + }); + + this.isConnected = false; + + this.isServiceStarted = false; + } + + static async from(host: string, options?: Options) { + const webSocket = new this(host, options); + + webSocket.start(); + + return webSocket; + } + + /** + * Open the WebSocket connection. + */ + public start() { + // If socket is open, not necessary to continue. + if (this.isConnected) return; + + // Defines and open the socket connection with the subprotocol if any. + this.socket = new WebSocket(this.getUrl(), this.options?.protocols); + + // Subscribe to events emited by the socket. + this.socket.onopen = this.onOpen.bind(this); + this.socket.onmessage = (event: MessageEvent) => + this.emit('message', event); + this.socket.onclose = this.onClose.bind(this); + this.socket.onerror = this.onError.bind(this); + + this.isServiceStarted = true; + + // Run the polling function that tracks the 'isConnected' socket status. + this.updateWebsocketClientIsConnectedStatus(); + } + + public stop(): void { + this.isServiceStarted = false; + + // If there is no socket defined is not necessary to continue. + if (!this.socket) return; + + this.socket.close(); + + this.socket = undefined; + } + + /** + * Transmits data using the WebSocket connection. + * + * @throws If was not able to connect with the server to send the message. + */ + public async send( + data: string | ArrayBuffer | Blob | ArrayBufferView, + ): Promise { + // If socket is not connected try to reconnet it before send the message. + if (!this.isConnected) await this.reconnect(); + + this.socket!.send(data); + } + + private onOpen(): void { + this.isConnected = true; + + this.emit('isOpen', true); + } + + private async onClose(): Promise { + this.isConnected = false; + + this.emit('isOpen', false); + + // If connection closes due to some error, but the service is started, try to reconnect. + if (this.isServiceStarted) await this.reconnect(); + } + + private onError(event: Event): void { + const error = new Error('WebSocket error'); + if ('error' in event) { + Object.assign(error, event); + } + + this.emit('error', error); + } + + /** + * Try to reconnects with the server using an exponential backoff approach. + */ + private async reconnect(): Promise { + await this.exponentialBackoff.run(async () => { + // If socket is 'undefined' or is 'CLOSED' try to start. + if (!this.socket || this.socket.readyState === WebSocket.CLOSED) { + this.start(); + + throw new Error( + `WebSocket was not initialized, trying to reconnect to ${this.getUrl()}`, + ); + } + + // If socket is already open just return; + if (this.socket!.readyState === WebSocket.OPEN) return; + + if (this.socket!.readyState === WebSocket.CONNECTING) { + try { + // Socket should jump from 'CONNECTING' to 'OPEN' state quite quickly. + // Here we just waits that happens for 5 seconds wich is good timing even for very bad connections. + await this.waitFor('isOpen', (isOpen) => isOpen, 5000); + } catch (error: unknown) { + console.debug(error); + + this.socket!.close(); + + throw new Error( + `Error trying to establish WebSocket connection to ${this.getUrl()}, stuck at 'CONNECTING'`, + ); + } + } + + if (this.socket!.readyState === WebSocket.CLOSING) { + try { + // Socket should jump from 'CLOSING' to 'CLOSED' state quite quickly. + // Here we just waits that happens for 2 seconds or force socket destruction. + await this.waitFor('isOpen', (isOpen) => !isOpen, 2000); + } catch (error: unknown) { + console.debug(error); + + // Detaching socket event emitter functions to avoid memory leaks. + this.socket.onopen = null; + this.socket.onclose = null; + this.socket.onmessage = null; + this.socket.onerror = null; + this.socket = undefined; + + throw new Error( + `Error trying to close WebSocket connection from ${this.getUrl()}, stuck at 'CLOSING'`, + ); + } + } + }); + } + + /** + * Update the WebSocket Client 'isConnected' status every 1 second as long as the service has been started. + */ + private async updateWebsocketClientIsConnectedStatus(): Promise { + while (this.isServiceStarted) { + await new Promise((resolve) => { + this.isConnected = this.socket?.readyState === WebSocket.OPEN; + + // Resolve the promise after 1 second. + setTimeout(resolve, 1000); + }); + } + } + + /** + * Util function to transfrom 'https:' urls to 'wss:' urls + */ + private getUrl(): string { + let url = ''; + + if (!this.host.includes('wss://') && !this.host.includes('ws://')) + // Use encrypted connection by default + url = 'wss://'; + + url += this.options?.port ? `${this.host}:${this.options.port}` : this.host; + + return url; + } +} diff --git a/packages/stack/src/wallet/base-wallet.ts b/packages/stack/src/wallet/base-wallet.ts index 8c69fbe..c3f36fc 100644 --- a/packages/stack/src/wallet/base-wallet.ts +++ b/packages/stack/src/wallet/base-wallet.ts @@ -159,7 +159,7 @@ export abstract class BaseWallet< }); return unspentTokens; - }; + } /** * Fetch all unspents from the blockchain belonging to this wallet. diff --git a/packages/stack/tests/blockchain/blockchain-e2e.spec.ts b/packages/stack/tests/blockchain/blockchain-e2e.spec.ts new file mode 100644 index 0000000..b3fcf21 --- /dev/null +++ b/packages/stack/tests/blockchain/blockchain-e2e.spec.ts @@ -0,0 +1,148 @@ +// TODO: We want to create a uniform set of tests for our BaseBlockchain interface. +// We should repeat the tests for each provider that we have. +// The below is a **VERY ROUGH** example of how we might be able to do this. +// However, Chaingraph backend is currently broken for many queries, so let's not do this yet. + +import { beforeAll, describe, it, expect } from 'vitest'; +import { + BaseBlockchain, + BlockchainChaingraph, + BlockchainElectrum, +} from '../../src/index.js'; + +const adapters: Record< + string, + () => Promise> +> = { + chaingraph: async () => { + return await BlockchainChaingraph.from(); + }, + electrum: async () => { + return await BlockchainElectrum.from(); + }, +}; + +const testAddress = 'bitcoincash:qq974d8zzqxmkzk0xxlm6ryx3nqyq53u25w6mwm0rm'; +const testLockscript = '76a91454eaf78abae543d9d8c1cf87109cb261101714ea88ac'; +const testTxId = + '7b63d5923f76be4e49820c41ed3557a55d5ce33e429519c6b9773b3214be5cb6'; +const testBlockHeight = 800000; + +describe.each(Object.entries(adapters))('%s Adapter', (name, createAdapter) => { + let blockchain: BaseBlockchain; + + beforeAll(async () => { + blockchain = await createAdapter(); + }); + + it(`${name}.fetchUnspents()`, async () => { + const result = await blockchain.fetchUnspents(testAddress); + + expect(Array.isArray(result.toArray())).toBeTruthy(); + if (result.size > 0) { + const firstUTXO = result.toArray()[0]; + + expect(firstUTXO.utxo.outpoint).toHaveProperty('outpointTransactionHash'); + expect(firstUTXO.utxo.outpoint).toHaveProperty('outpointIndex'); + expect(firstUTXO.utxo.output).toHaveProperty('valueSatoshis'); + } + }); + + it(`${name}.fetchTransaction() `, async () => { + const result = await blockchain.fetchTransaction(testTxId); + + expect(result).toBeDefined(); + expect(Array.isArray(result.getInputs())).toBeTruthy(); + expect(result.getInputs()[0]).toHaveProperty('outpointTransactionHash'); + expect(Array.isArray(result.getOutputs())).toBeTruthy(); + expect(result.getOutputs()[0]).toHaveProperty('valueSatoshis'); + }); + + it(`${name}.fetchBlockHeader() `, async () => { + const result = await blockchain.fetchBlockHeader(testBlockHeight); + + expect(result).toBeDefined(); + expect(result.toHex()).toBeTypeOf('string'); + expect(result.getTimestamp()).toBeTypeOf('number'); + }); + + it(`${name}.fetchChainTip() `, async () => { + const result = await blockchain.fetchChainTip(); + + expect(result).toBeDefined(); + expect(result.height).toBeTypeOf('number'); + expect(result.blockHeader.getHashHex()).toBeTypeOf('string'); + }); + + it(`${name}.subscribeLockscript()`, async () => { + const subscriptionPromise = new Promise((resolve, reject) => { + blockchain + .subscribeLockscript(testLockscript, () => {}) + .then((unsubscribeCallback) => resolve(unsubscribeCallback)) + .catch((error) => reject(error)); + }); + + // Should return and unsuscription callback after a successful suscription. + await expect(subscriptionPromise).resolves.toBeTypeOf('function'); + }); + + it(`${name}.subscribeAddress(), `, async () => { + const subscriptionPromise = new Promise((resolve, reject) => { + blockchain + .subscribeAddress(testAddress, () => {}) + .then((unsubscribeCallback) => resolve(unsubscribeCallback)) + .catch((error) => reject(error)); + }); + + // Should return and unsuscription callback after a successful suscription. + await expect(subscriptionPromise).resolves.toBeTypeOf('function'); + }); + + it.sequential(`${name}.subscribeTransaction()`, async () => { + const subscriptionPromise = new Promise((resolve, reject) => { + blockchain + .subscribeTransaction(testTxId, () => {}) + .then((unsubscribeCallback) => resolve(unsubscribeCallback)) + .catch((error) => reject(error)); + }); + + // Should return and unsuscription callback after a successful suscription. + await expect(subscriptionPromise).resolves.toBeTypeOf('function'); + }); + + it(`${name}.unsubscribeLockscript()`, async () => { + const unsubscriptionPromise = new Promise((resolve, reject) => { + blockchain + .unsubscribeLockscript(testLockscript, () => {}) + .then((result) => resolve(result)) + .catch((error) => reject(error)); + }); + + // Should return true after a successful unsubscription. + await expect(unsubscriptionPromise).resolves.toBeTypeOf('boolean'); + }); + + it(`${name}.unsubscribeAddress()`, async () => { + const unsubscriptionPromise = new Promise((resolve, reject) => { + blockchain + .unsubscribeAddress(testAddress, () => {}) + .then((result) => resolve(result)) + .catch((error) => reject(error)); + }); + + // Should return true after a successful unsubscription. + await expect(unsubscriptionPromise).resolves.toBeTypeOf('boolean'); + }); + + it.sequential(`${name}.unsubscribeTransaction()`, async () => { + const unsubscriptionPromise = new Promise((resolve, reject) => { + blockchain + .unsubscribeTransaction(testTxId, () => {}) + .then((result) => resolve(result)) + .catch((error) => reject(error)); + }); + + // Should return true after a successful unsubscription. + await expect(unsubscriptionPromise).resolves.toBeTypeOf('boolean'); + }); +}); diff --git a/packages/stack/tests/utils/graphql-client.spec.ts b/packages/stack/tests/utils/graphql-client.spec.ts new file mode 100644 index 0000000..64bd2ee --- /dev/null +++ b/packages/stack/tests/utils/graphql-client.spec.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { GraphQLClient } from '../../src/utils/index.js'; +import { + monitorSyncTipsSubscriptionQuery, + monitorSyncTipsQuery, + monitorSyncTipsSchema, +} from '../../src/blockchain/blockchain-chaingraph-helpers.js'; + +const chaingraphServer = 'https://demo.chaingraph.cash/v1/graphql'; + +describe('GraphQLClient', () => { + const graphQLClient = new GraphQLClient(chaingraphServer); + + it("should succesfully subscribe to 'MonitorSyncTips' messages and receive a valid response from server", async () => { + /** + * Query in SDL: + * + `subscription MonitorSyncTips ($nameFilter: String = "bchn-mainnet") { + node (where: { name: { _like: $nameFilter } }) { + name + user_agent + latest_tip: accepted_blocks( + limit: 1, + order_by: { block_internal_id: desc } + ) { + accepted_at + block { + hash + height + } + } + } + }` + */ + const response = await graphQLClient.subscribe( + 'MonitorSyncTipsSubscriptionTest', + monitorSyncTipsSubscriptionQuery, + true, // wait For response on subscribe + { + chainFilter: 'bchn-chipnet', + }, + ); + + expect(response).toBeTypeOf('object'); + expect(response).not.toBeInstanceOf(Error); + expect(response).not.toBeNull(); + + await graphQLClient.unsubscribe(['MonitorSyncTipsSubscriptionTest']); + }); + + it("should succesfully query latest 'MonitorSyncTips'", async () => { + /** + * Query in SDL: + * + `query MonitorSyncTips ( $chainFilter: String = "bchn-mainnet" ) { + node ( where: { name: { _like: $chainFilter } } ) { + name + user_agent + latest_tip: accepted_blocks( + limit: 1, + order_by: { block_internal_id: desc } + ) { + accepted_at + block { + hash + height + } + } + } + }` + */ + const response = await graphQLClient.request( + 'monitorSyncTipsQuery', + monitorSyncTipsQuery, + { chainFilter: 'bchn-mainnet' }, + ); + + // Validate the response notification payload. + monitorSyncTipsSchema.parse(response); + + expect(response).toBeTypeOf('object'); + expect(response).not.toBeInstanceOf(Error); + expect(response).not.toBeNull(); + }); + + it("should succesfully request 'GetJonathan100CashAccount' and receive a valid response from server", async () => { + /** + * Query in SDL: + * + `query GetJonathan100CashAccount { + search_output_prefix( + args: { locking_bytecode_prefix_hex: "6a0401010101084a6f6e617468616e" }, + where: { + transaction: { block_inclusions: { block: { height: { _eq: 563720 } } } } + } + ) { + transaction { + hash + data_carrier_outputs { + locking_bytecode + output_index + } + block_inclusions { + block { + hash + height + } + } + } + } + }` + */ + const getJonathan100CashAccount = { + query: { + __name: 'GetJonathan100CashAccount', + search_output_prefix: { + __args: { + args: { + locking_bytecode_prefix_hex: '6a0401010101084a6f6e617468616e', + }, + where: { + transaction: { + block_inclusions: { + block: { + height: { + _eq: 563720, + }, + }, + }, + }, + }, + }, + transaction: { + hash: true, + data_carrier_outputs: { + locking_bytecode: true, + output_index: true, + }, + block_inclusions: { + block: { + hash: true, + height: true, + }, + }, + }, + }, + }, + }; + + const expectedResponse = { + data: { + search_output_prefix: [ + { + transaction: { + hash: '\\x590d1fdf7e04af0ee08f9194bb9e8d1971bdcbf55d29303d5bf32d4eae5e7136', + data_carrier_outputs: [ + { + locking_bytecode: + '\\x6a0401010101084a6f6e617468616e1501ebdeb6430f3d16a9c6758d6c0d7a400c8e6bbee4', + output_index: '0', + }, + ], + block_inclusions: [ + { + block: { + hash: '\\x000000000000000002abbeff5f6fb22a0b3b5c2685c6ef4ed2d2257ed54e9dcb', + height: '563720', + }, + }, + ], + }, + }, + ], + }, + }; + + const response = await graphQLClient.request( + 'GetJonathan100CashAccount', + getJonathan100CashAccount, + ); + + expect(response).toEqual(expectedResponse); + }); +}); diff --git a/packages/stack/tests/utils/json-to-graphql-query.spec.ts b/packages/stack/tests/utils/json-to-graphql-query.spec.ts new file mode 100644 index 0000000..ef64015 --- /dev/null +++ b/packages/stack/tests/utils/json-to-graphql-query.spec.ts @@ -0,0 +1,557 @@ +import { describe, it, expect } from 'vitest'; +import { + jsonToGraphQLQuery, + EnumType, + VariableType, +} from 'json-to-graphql-query'; + +describe('should convert json formated queries to GraphQL Schema Definition Language(SDL)', () => { + it('subscription query', () => { + const expectedQuery = `subscription + MonitorSyncTips ( $chainFilter: String = "bchn-mainnet" ) { + node ( where: { name: { _like: $chainFilter } } ) { + name + user_agent + latest_tip: accepted_blocks( + limit: 1, + order_by: { block_internal_id: desc } + ) { + accepted_at + block { + hash + height + } + } + } + }`; + + const monitorSyncTipsSubscriptionQuery = { + subscription: { + __name: 'MonitorSyncTips', + __variables: { + chainFilter: 'String = "bchn-mainnet"', // Default to 'bchn-mainnet' + }, + node: { + __args: { + where: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + name: true, + user_agent: true, + latest_tip: { + __aliasFor: 'accepted_blocks', + __args: { + limit: 1, + order_by: { block_internal_id: new EnumType('desc') }, + }, + accepted_at: true, + block: { + hash: true, + height: true, + }, + }, + }, + }, + }; + + expect( + normalizeString(jsonToGraphQLQuery(monitorSyncTipsSubscriptionQuery)), + ).toEqual(normalizeString(expectedQuery)); + }); + + it('request query', () => { + const expectedQuery = `query + GetJonathan100CashAccount { + search_output_prefix( + args: { locking_bytecode_prefix_hex: "6a0401010101084a6f6e617468616e" }, + where: { + transaction: { block_inclusions: { block: { height: { _eq: 563720 } } } } + } + ) { + transaction { + hash + data_carrier_outputs { + locking_bytecode + output_index + } + block_inclusions { + block { + hash + height + } + } + } + } + }`; + + const getJonathan100CashAccountRequestQuery = { + query: { + __name: 'GetJonathan100CashAccount', + search_output_prefix: { + __args: { + args: { + locking_bytecode_prefix_hex: '6a0401010101084a6f6e617468616e', + }, + where: { + transaction: { + block_inclusions: { + block: { + height: { + _eq: 563720, + }, + }, + }, + }, + }, + }, + transaction: { + hash: true, + data_carrier_outputs: { + locking_bytecode: true, + output_index: true, + }, + block_inclusions: { + block: { + hash: true, + height: true, + }, + }, + }, + }, + }, + }; + + expect( + normalizeString( + jsonToGraphQLQuery(getJonathan100CashAccountRequestQuery), + ), + ).toEqual(normalizeString(expectedQuery)); + }); + + it('fetch unspent transaction outputs for locking bytecode hex query', () => { + const expectedQuery = `query + SearchOutputsByLockingBytecode( $chainFilter: String = "bchn-mainnet", $lockingBytecodeHexObject: String ) { + search_output( + args: { + locking_bytecode_hex: $lockingBytecodeHexObject + }, + where: { + _not: { spent_by: {} }, + transaction: { block_inclusions: { block: { accepted_by: { node: { name: { _like: $chainFilter } } } } } } + } + ) { + locking_bytecode + output_index + transaction_hash + value_satoshis + token_category + fungible_token_amount + nonfungible_token_capability + nonfungible_token_commitment + transaction { + block_inclusions { + block { + height + } + } + } + spent_by { + input_index + } + } + }`; + + const searchOutputsByLockingBytecodeQuery = { + query: { + __name: 'SearchOutputsByLockingBytecode', + __variables: { + chainFilter: 'String = "bchn-mainnet"', + lockingBytecodeHexObject: 'String', + }, + search_output: { + __args: { + args: { + locking_bytecode_hex: new VariableType( + 'lockingBytecodeHexObject', + ), + }, + where: { + _not: { spent_by: {} }, + transaction: { + block_inclusions: { + block: { + accepted_by: { + node: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + }, + }, + }, + }, + }, + locking_bytecode: true, + output_index: true, + transaction_hash: true, + value_satoshis: true, + token_category: true, + fungible_token_amount: true, + nonfungible_token_capability: true, + nonfungible_token_commitment: true, + transaction: { + block_inclusions: { + block: { + height: true, + }, + }, + }, + spent_by: { + input_index: true, + }, + }, + }, + }; + + expect( + normalizeString(jsonToGraphQLQuery(searchOutputsByLockingBytecodeQuery)), + ).toEqual(normalizeString(expectedQuery)); + }); + + it('Get Transaction Encoded Hex query', () => { + const expectedQuery = `query + GetTransactionHex($chainFilter: String = "bchn-mainnet", $txHash: String) { + transaction(where: { + hash: { + _eq: $txHash + }, + block_inclusions: { + block:{ + accepted_by:{ + node: { + name: { + _like: $chainFilter + } + } + } + } + } + }) { + encoded_hex + } + }`; + + const getTransactionHex = { + query: { + __name: 'GetTransactionHex', + __variables: { + chainFilter: 'String = "bchn-mainnet"', + txHash: 'String', + }, + transaction: { + __args: { + where: { + hash: { _eq: new VariableType('txHash') }, + block_inclusions: { + block: { + accepted_by: { + node: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + }, + }, + }, + }, + encoded_hex: true, + }, + }, + }; + + expect(normalizeString(jsonToGraphQLQuery(getTransactionHex))).toEqual( + normalizeString(expectedQuery), + ); + }); + + it('Get Block Header Hex query', () => { + const expectedQuery = `query + GetBlockHeaderHex($chainFilter: String = "bchn-mainnet", $blockHeight: bigint) { + block(where: { + height: { + _eq: $blockHeight + }, + accepted_by: { + block:{ + accepted_by:{ + node: { + name: { + _like: $chainFilter + } + } + } + } + } + }) { + header + } + }`; + + const getBlockHeaderHex = { + query: { + __name: 'GetBlockHeaderHex', + __variables: { + chainFilter: 'String = "bchn-mainnet"', + blockHeight: 'bigint', + }, + block: { + __args: { + where: { + height: { _eq: new VariableType('blockHeight') }, + accepted_by: { + block: { + accepted_by: { + node: { + name: { + _like: new VariableType('chainFilter'), + }, + }, + }, + }, + }, + }, + }, + header: true, + }, + }, + }; + + expect(normalizeString(jsonToGraphQLQuery(getBlockHeaderHex))).toEqual( + normalizeString(expectedQuery), + ); + }); + + it('Monitor For Payments subscription query', () => { + const expectedQuery = `subscription + MonitorForPayments( + $lockscriptHexObject: _text + ) { + search_output( + args: { + locking_bytecode_hex: $lockscriptHexObject + }, where:{ + transaction: { + _not: {block_inclusions: {}} + } + } + ) { + locking_bytecode + transaction_hash + value_satoshis + transaction { + node_validations { + validated_at + } + } + } + }`; + + const monitorForPayments = { + subscription: { + __name: 'MonitorForPayments', + __variables: { + lockscriptHexObject: '_text', + }, + search_output: { + __args: { + args: { + locking_bytecode_hex: new VariableType('lockscriptHexObject'), + }, + where: { + transaction: { + _not: { block_inclusions: {} }, + }, + }, + }, + locking_bytecode: true, + transaction_hash: true, + value_satoshis: true, + transaction: { + node_validations: { + validated_at: true, + }, + }, + }, + }, + }; + + expect(normalizeString(jsonToGraphQLQuery(monitorForPayments))).toEqual( + normalizeString(expectedQuery), + ); + }); + + it('Monitor Transaction Hash subscription query', () => { + const expectedQuery = `subscription + MonitorTransactionHash( + $transactionHash: bytea + ) { + transaction(where:{node_validations:{ + transaction:{_not:{block_inclusions:{}}, + hash:{_eq: $transactionHash}}}}) + { + hash + node_validations { + validated_at + } + } + }`; + + const monitorTransactionHash = { + subscription: { + __name: 'MonitorTransactionHash', + __variables: { + transactionHash: 'bytea', + }, + transaction: { + __args: { + where: { + node_validations: { + transaction: { + _not: { block_inclusions: {} }, + hash: { _eq: new VariableType('transactionHash') }, + }, + }, + }, + }, + hash: true, + node_validations: { + validated_at: true, + }, + }, + }, + }; + + expect(normalizeString(jsonToGraphQLQuery(monitorTransactionHash))).toEqual( + normalizeString(expectedQuery), + ); + }); + + it('Get Transaction Details query', () => { + const expectedQuery = `query + GetLockscriptHistory( + $lockscriptHex: bytea + ) { + transaction(where: { + outputs: { + locking_bytecode: { _eq: $lockscriptHex } + }, + inputs:{ + outpoint:{ + locking_bytecode: { _eq: $lockscriptHex } + } + } + } ) + { + hash + is_coinbase + block_inclusions { + block { + height + } + } + } + }`; + + const getLockscriptHistory = { + query: { + __name: 'GetLockscriptHistory', + __variables: { + lockscriptHex: 'bytea', + }, + transaction: { + __args: { + where: { + outputs: { + locking_bytecode: { _eq: new VariableType('lockscriptHex') }, + }, + inputs: { + outpoint: { + locking_bytecode: { _eq: new VariableType('lockscriptHex') }, + }, + }, + }, + }, + hash: true, + is_coinbase: true, + block_inclusions: { + block: { + height: true, + }, + }, + }, + }, + }; + + expect(normalizeString(jsonToGraphQLQuery(getLockscriptHistory))).toEqual( + normalizeString(expectedQuery), + ); + }); + + it('Send Raw Transaction Mutation query', () => { + const expectedQuery = `mutation + SendRawTransaction( + $rawTransactionHex: String!, + $nodeInternalId: bigint! + ) { + send_transaction( + request: { + node_internal_id: $nodeInternalId, + encoded_hex: $rawTransactionHex + } + ) { + transaction_hash + validation_error_message + validation_success + transmission_error_message + transmission_success + } + }`; + + const sendRawTransactionMutation = { + mutation: { + __name: 'SendRawTransaction', + __variables: { + rawTransactionHex: 'String!', + nodeInternalId: 'bigint!', + }, + send_transaction: { + __args: { + request: { + node_internal_id: new VariableType('nodeInternalId'), + encoded_hex: new VariableType('rawTransactionHex'), + }, + }, + transaction_hash: true, + validation_error_message: true, + validation_success: true, + transmission_error_message: true, + transmission_success: true, + }, + }, + }; + + expect( + normalizeString(jsonToGraphQLQuery(sendRawTransactionMutation)), + ).toEqual(normalizeString(expectedQuery)); + }); +}); + +function normalizeString(str: string): string { + // Removes all whitespace (spaces, line breaks, tabs) + return str.replace(/\s+/g, ''); +} diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index 718f79f..24b09b6 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -4,15 +4,8 @@ "outDir": "./dist", "rootDir": "./src", "baseUrl": ".", - "lib": [ - "dom" - ] + "lib": ["dom"] }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] }