Resolve "Blockchain Adapter: Add Chaingraph Adapter"

This commit is contained in:
devperate
2025-08-25 03:57:00 +00:00
committed by Jim Hamill
parent c16f5e6fd0
commit dffa72d850
16 changed files with 3103 additions and 20 deletions
+17 -1
View File
@@ -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"
}
}
}
}
+2 -1
View File
@@ -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",
@@ -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<ChainNames>),
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(),
}),
}),
});
@@ -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<string>;
application?: string;
chain?: ChainNames;
};
export type ChaingraphLockscriptNotification = {
transactionHash: string;
valueSatoshis: number;
};
export type ChaingraphCustomSubscriptionNotification = Record<string, unknown>;
type CustomSubscriptionReturnedObject = {
unsubscribeCallback: () => Promise<boolean>;
subscriptionResponse?: GraphqlRequestResponse;
};
export class BlockchainChaingraph
extends EventEmitter<BlockchainEvents>
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<BlockchainCallback<ChaingraphLockscriptNotification>>
> = new Map();
// Transaction Subscription State.
transactionSubscriptions: Map<
string,
Set<BlockchainCallback<undefined>>
> = new Map();
// Custom Subscriptions.
customSubscriptions: Map<
string,
Set<BlockchainCallback<ChaingraphCustomSubscriptionNotification>>
> = new Map();
// Options
store: BaseStore;
servers: Array<string>;
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<T extends typeof BlockchainChaingraph>(
this: T,
opts: BlockchainChaingraphOpts = {},
) {
const blockchain = new this(opts);
await blockchain.start();
return blockchain as InstanceType<T>;
}
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<BlockchainUTXO> = 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<BlockchainTransaction> = 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<ChaingraphLockscriptNotification>,
) {
// 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<ChaingraphLockscriptNotification>,
): Promise<boolean> {
// 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<ChaingraphLockscriptNotification>,
) {
// 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<ChaingraphLockscriptNotification>,
) {
// 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<undefined>,
) {
// 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<undefined>,
) {
// 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<GraphqlRequestResponse> {
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<ChaingraphCustomSubscriptionNotification>,
): Promise<CustomSubscriptionReturnedObject> {
// 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 <Omit> 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<void>((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;
}
}
@@ -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;
+6
View File
@@ -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;
}
+1
View File
@@ -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';
+465
View File
@@ -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<string, string | number>;
// Shape of the Connection Initialization Message chaingraph accepts.
type ConnectionInitMessage = {
type: 'connection_init';
// 'Record<never, never>' stand for empty payload.
payload: Record<never, never> | 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<string, unknown>;
};
// 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<string, unknown>
| Error
| boolean;
export type GraphQLClientEvents<T extends string = string> = {
/**
* 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.
* '<SubscriptionId>' : Record<string, unknown> emitted when server send a message that carries updated data of a subscription made.
*
* @eventProperty
*/
[K in T]: GraphqlClientEventsPayload;
};
export class GraphQLClient extends EventEmitter<GraphQLClientEvents> {
// 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<void> {
// 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<RequestResponseWithData>} 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<GraphqlRequestResponse> {
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<GraphqlRequestResponse>} A promise that resolves with data for the given query if successfull.
* @returns {Promise<void>} 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<GraphqlRequestResponse | void> {
// 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 {<GraphQLClientEventsPayload>} 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<string>} queryIds A list of queryId.
*/
async unsubscribe(queryIds: Array<string>): Promise<void> {
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<void> => {
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<void> {
// 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() })),
}),
]);
}
+2
View File
@@ -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';
+1 -1
View File
@@ -3,7 +3,7 @@
export type MemoizerItem<T> = {
value: T;
timestamp: number;
}
};
export class Memoizer {
private cache = new Map<string, MemoizerItem<any>>();
+250
View File
@@ -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> | string;
/**
* Defines the amount of reconnection attempts before throwing failure.
*/
reconnectionAttempts?: number;
};
/**
* A custom Websocket Client implementation.
*/
export class WebsocketClient extends EventEmitter<WebSocketEvents> {
// 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<void> {
// 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<void> {
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<void> {
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<void> {
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;
}
}
+1 -1
View File
@@ -159,7 +159,7 @@ export abstract class BaseWallet<
});
return unspentTokens;
};
}
/**
* Fetch all unspents from the blockchain belonging to this wallet.
@@ -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<BaseBlockchain<unknown, unknown>>
> = {
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<unknown, unknown>;
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');
});
});
@@ -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);
});
});
@@ -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, '');
}
+3 -10
View File
@@ -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"]
}