diff --git a/README.md b/README.md index 770c8b8..da7c171 100644 --- a/README.md +++ b/README.md @@ -12,33 +12,7 @@ yarn add stxer ## Features -### 1. Batch Operations - -The SDK provides efficient batch reading capabilities for Stacks blockchain: - -```typescript -import { batchRead, batchReadonly } from 'stxer'; - -// Batch read variables and maps -const result = await batchRead({ - variables: [{ - contract: contractPrincipalCV(...), - variableName: 'my-var' - }], - maps: [{ - contract: contractPrincipalCV(...), - mapName: 'my-map', - mapKey: someCV - }], - readonly: [{ - contract: contractPrincipalCV(...), - functionName: 'my-function', - functionArgs: [/* clarity values */] - }] -}); -``` - -### 2. Transaction Simulation +### 1. Transaction Simulation Simulate complex transaction sequences before executing them on-chain: @@ -67,6 +41,108 @@ const simulationId = await SimulationBuilder.new({ // View simulation results at: https://stxer.xyz/simulations/{network}/{simulationId} ``` +### 2. Batch Operations + +The SDK provides two approaches for efficient batch reading from the Stacks blockchain: + +#### Direct Batch Reading + +```typescript +import { batchRead } from 'stxer'; + +// Batch read variables and maps +const result = await batchRead({ + variables: [{ + contract: contractPrincipalCV(...), + variableName: 'my-var' + }], + maps: [{ + contract: contractPrincipalCV(...), + mapName: 'my-map', + mapKey: someCV + }], + readonly: [{ + contract: contractPrincipalCV(...), + functionName: 'my-function', + functionArgs: [/* clarity values */] + }] +}); +``` + +#### BatchProcessor for Queue-based Operations + +The BatchProcessor allows you to queue multiple read operations and automatically batch them together after a specified delay: + +```typescript +import { BatchProcessor } from 'stxer'; + +const processor = new BatchProcessor({ + stxerAPIEndpoint: 'https://api.stxer.xyz', // optional + batchDelayMs: 1000, // delay before processing batch +}); + +// Queue multiple operations that will be batched together +const [resultA, resultB] = await Promise.all([ + new Promise((resolve, reject) => { + processor.enqueue({ + request: { + mode: 'variable', + contractAddress: 'SP...', + contractName: 'my-contract', + variableName: 'variable-a' + }, + resolve, + reject + }); + }), + new Promise((resolve, reject) => { + processor.enqueue({ + request: { + mode: 'variable', + contractAddress: 'SP...', + contractName: 'my-contract', + variableName: 'variable-b' + }, + resolve, + reject + }); + }) +]); + +// You can also queue different types of operations +processor.enqueue({ + request: { + mode: 'readonly', + contractAddress: 'SP...', + contractName: 'my-contract', + functionName: 'get-value', + functionArgs: [] + }, + resolve: (value) => console.log('Function result:', value), + reject: (error) => console.error('Error:', error) +}); + +processor.enqueue({ + request: { + mode: 'mapEntry', + contractAddress: 'SP...', + contractName: 'my-contract', + mapName: 'my-map', + mapKey: someKey + }, + resolve: (value) => console.log('Map entry:', value), + reject: (error) => console.error('Error:', error) +}); +``` + +The BatchProcessor automatically: +- Queues read operations +- Batches them together after the specified delay +- Makes a single API call for all queued operations +- Distributes results back to the respective promises + +This is particularly useful when you need to make multiple blockchain reads and want to optimize network calls. + ## Configuration You can customize the API endpoints: diff --git a/src/batch-api.ts b/src/BatchAPI.ts similarity index 100% rename from src/batch-api.ts rename to src/BatchAPI.ts diff --git a/src/BatchProcessor.ts b/src/BatchProcessor.ts new file mode 100644 index 0000000..b7039d1 --- /dev/null +++ b/src/BatchProcessor.ts @@ -0,0 +1,172 @@ +/** + * WARNING: + * + * this file will be used in cross-runtime environments (browser, cloudflare workers, XLinkSDK, etc.), + * so please be careful when adding `import`s to it. + */ + +import { + type ClarityValue, + type OptionalCV, + contractPrincipalCV, +} from '@stacks/transactions'; +import { type BatchReads, batchRead } from './BatchAPI'; + +export interface ReadOnlyRequest { + mode: 'readonly'; + contractAddress: string; + contractName: string; + functionName: string; + functionArgs: ClarityValue[]; +} + +export interface MapEntryRequest { + mode: 'mapEntry'; + contractAddress: string; + contractName: string; + mapName: string; + mapKey: ClarityValue; +} + +export interface VariableRequest { + mode: 'variable'; + contractAddress: string; + contractName: string; + variableName: string; +} + +export type BatchRequest = MapEntryRequest | VariableRequest | ReadOnlyRequest; + +export interface QueuedRequest { + request: BatchRequest; + tip?: string; + resolve: (value: ClarityValue | OptionalCV) => void; + reject: (error: Error) => void; +} + +export class BatchProcessor { + private queues = new Map(); + private timeoutIds = new Map>(); + + private readonly stxerAPIEndpoint: string; + private readonly batchDelayMs: number; + + constructor(options: { stxerAPIEndpoint?: string; batchDelayMs: number }) { + this.stxerAPIEndpoint = options.stxerAPIEndpoint ?? 'https://api.stxer.xyz'; + this.batchDelayMs = options.batchDelayMs; + } + + private getQueueKey(tip?: string): string { + return tip ?? '_undefined'; + } + + enqueue(request: QueuedRequest): void { + const queueKey = this.getQueueKey(request.tip); + + const queue = this.queues.get(queueKey) ?? []; + if (!this.queues.has(queueKey)) { + this.queues.set(queueKey, queue); + } + queue.push(request); + + if (!this.timeoutIds.has(queueKey)) { + const timeoutId = setTimeout( + () => this.processBatch(queueKey), + this.batchDelayMs, + ); + this.timeoutIds.set(queueKey, timeoutId); + } + } + + private async processBatch(queueKey: string): Promise { + const currentQueue = this.queues.get(queueKey) ?? []; + this.queues.delete(queueKey); + + const timeoutId = this.timeoutIds.get(queueKey); + if (timeoutId) { + clearTimeout(timeoutId); + this.timeoutIds.delete(queueKey); + } + + if (currentQueue.length === 0) return; + + try { + const readonlyRequests = currentQueue.filter( + (q): q is QueuedRequest & { request: ReadOnlyRequest } => + q.request.mode === 'readonly', + ); + const mapRequests = currentQueue.filter( + (q): q is QueuedRequest & { request: MapEntryRequest } => + q.request.mode === 'mapEntry', + ); + const variableRequests = currentQueue.filter( + (q): q is QueuedRequest & { request: VariableRequest } => + q.request.mode === 'variable', + ); + + const tip = queueKey === '_undefined' ? undefined : queueKey; + + const batchRequest: BatchReads = { + readonly: readonlyRequests.map(({ request }) => ({ + contract: contractPrincipalCV( + request.contractAddress, + request.contractName, + ), + functionName: request.functionName, + functionArgs: request.functionArgs, + })), + maps: mapRequests.map(({ request }) => ({ + contract: contractPrincipalCV( + request.contractAddress, + request.contractName, + ), + mapName: request.mapName, + mapKey: request.mapKey, + })), + variables: variableRequests.map(({ request }) => ({ + contract: contractPrincipalCV( + request.contractAddress, + request.contractName, + ), + variableName: request.variableName, + })), + index_block_hash: tip, + }; + + const results = await batchRead(batchRequest, { + stxerApi: this.stxerAPIEndpoint, + }); + + // Handle readonly results + for (const [index, result] of results.readonly.entries()) { + if (result instanceof Error) { + readonlyRequests[index].reject(result); + } else { + readonlyRequests[index].resolve(result); + } + } + + // Handle variable results + for (const [index, result] of results.vars.entries()) { + if (result instanceof Error) { + variableRequests[index].reject(result); + } else { + variableRequests[index].resolve(result); + } + } + + // Handle map results + for (const [index, result] of results.maps.entries()) { + if (result instanceof Error) { + mapRequests[index].reject(result); + } else { + mapRequests[index].resolve(result); + } + } + } catch (error) { + for (const item of currentQueue) { + item.reject(error as Error); + } + } + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index aa692ee..1ba9b7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export * from './batch-api'; +export * from './BatchAPI'; export * from './simulation'; diff --git a/src/sample/read.ts b/src/sample/read.ts index 8f5718e..06f27e4 100644 --- a/src/sample/read.ts +++ b/src/sample/read.ts @@ -5,7 +5,8 @@ import { tupleCV, uintCV, } from '@stacks/transactions'; -import { batchRead } from '../batch-api'; +import { batchRead } from '../BatchAPI'; +import { BatchProcessor } from '../BatchProcessor'; async function batchReadsExample() { const rs = await batchRead({ @@ -72,9 +73,46 @@ async function batchReadsExample() { console.log(rs); } +async function batchQueueProcessorExample() { + const processor = new BatchProcessor({ + stxerAPIEndpoint: 'https://api.stxer.xyz', + batchDelayMs: 1000, + }); + + + const promiseA = new Promise((resolve, reject) => { + processor.enqueue({ + request: { + mode: 'variable', + contractAddress: 'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275', + contractName: 'liquidity-token-v5kbe3oqvac', + variableName: 'balance-x', + }, + resolve: resolve, + reject: reject, + }); + }); + + const promiseB = new Promise((resolve, reject) => { + processor.enqueue({ + request: { + mode: 'variable', + contractAddress: 'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275', + contractName: 'liquidity-token-v5kbe3oqvac', + variableName: 'balance-y', + }, + resolve: resolve, + reject: reject, + }); + }); + + const result = await Promise.all([promiseA, promiseB]); + console.log(result); +} + async function main() { await batchReadsExample(); - // await batchReadonlyExample(); + await batchQueueProcessorExample(); } if (require.main === module) {