Resolve "Blockchain Adapter: Add Chaingraph Adapter"
This commit is contained in:
Generated
+17
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() })),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export type MemoizerItem<T> = {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
}
|
||||
};
|
||||
|
||||
export class Memoizer {
|
||||
private cache = new Map<string, MemoizerItem<any>>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user