diff --git a/solend-sdk/package.json b/solend-sdk/package.json index a464f02f..a5a84870 100644 --- a/solend-sdk/package.json +++ b/solend-sdk/package.json @@ -30,6 +30,7 @@ "bn.js": "^5.2.0", "buffer": "^6.0.3", "buffer-layout": "^1.2.0", + "hot-shots": "^9.3.0", "isomorphic-fetch": "^3.0.0", "jsbi": "^4.3.0", "typedoc-plugin-cname": "^1.0.1" diff --git a/solend-sdk/src/utils/rpc.ts b/solend-sdk/src/utils/rpc.ts new file mode 100644 index 00000000..31970ed5 --- /dev/null +++ b/solend-sdk/src/utils/rpc.ts @@ -0,0 +1,493 @@ +import { + AccountInfo, + Blockhash, + BlockhashWithExpiryBlockHeight, + Commitment, + ConfirmedSignatureInfo, + ConfirmedSignaturesForAddress2Options, + FeeCalculator, + Finality, + GetAccountInfoConfig, + GetLatestBlockhashConfig, + GetMultipleAccountsConfig, + GetProgramAccountsConfig, + GetSlotConfig, + GetTransactionConfig, + Message, + PublicKey, + RpcResponseAndContext, + SendOptions, + Signer, + SimulatedTransactionResponse, + TokenAmount, + Transaction, + TransactionResponse, + TransactionSignature, +} from "@solana/web3.js"; +import { StatsD } from "hot-shots"; + +// Connection and MultiConnection should both implement SolendRPCConnection +export interface SolendRPCConnection { + getAccountInfo( + publicKey: PublicKey, + commitmentOrConfig?: Commitment | GetAccountInfoConfig + ): Promise | null>; + getConfirmedSignaturesForAddress2( + address: PublicKey, + options?: ConfirmedSignaturesForAddress2Options, + commitment?: Finality + ): Promise>; + getLatestBlockhash( + commitmentOrConfig?: Commitment | GetLatestBlockhashConfig + ): Promise; + getMultipleAccountsInfo( + publicKeys: PublicKey[], + commitmentOrConfig?: Commitment | GetMultipleAccountsConfig + ): Promise<(AccountInfo | null)[]>; + getProgramAccounts( + programId: PublicKey, + configOrCommitment?: GetProgramAccountsConfig | Commitment + ): Promise< + Array<{ + pubkey: PublicKey; + account: AccountInfo; + }> + >; + getRecentBlockhash(commitment?: Commitment): Promise<{ + blockhash: Blockhash; + feeCalculator: FeeCalculator; + }>; + getSlot(commitmentOrConfig?: Commitment | GetSlotConfig): Promise; + getTokenAccountBalance( + tokenAddress: PublicKey, + commitment?: Commitment + ): Promise>; + getTokenSupply( + tokenMintAddress: PublicKey, + commitment?: Commitment + ): Promise>; + getTransaction( + signature: string, + rawConfig?: GetTransactionConfig + ): Promise; + sendTransaction( + transaction: Transaction, + signers: Array, + options?: SendOptions + ): Promise; + simulateTransaction( + transactionOrMessage: Transaction | Message, + signers?: Array, + includeAccounts?: boolean | Array + ): Promise>; +} + +// MultiConnection implements SolendRPCConnection +export class MultiConnection { + connections: SolendRPCConnection[]; + constructor(connections: SolendRPCConnection[]) { + this.connections = connections; + } + + getAccountInfo( + publicKey: PublicKey, + commitmentOrConfig?: Commitment | GetAccountInfoConfig + ): Promise | null> { + return Promise.race( + this.connections.map((c) => + c.getAccountInfo(publicKey, commitmentOrConfig) + ) + ); + } + getConfirmedSignaturesForAddress2( + address: PublicKey, + options?: ConfirmedSignaturesForAddress2Options, + commitment?: Finality + ): Promise> { + return Promise.race( + this.connections.map((c) => + c.getConfirmedSignaturesForAddress2(address, options, commitment) + ) + ); + } + getLatestBlockhash( + commitmentOrConfig?: Commitment | GetLatestBlockhashConfig + ): Promise { + return Promise.race( + this.connections.map((c) => c.getLatestBlockhash(commitmentOrConfig)) + ); + } + getMultipleAccountsInfo( + publicKeys: PublicKey[], + commitmentOrConfig?: Commitment | GetMultipleAccountsConfig + ): Promise<(AccountInfo | null)[]> { + return Promise.race( + this.connections.map((c) => + c.getMultipleAccountsInfo(publicKeys, commitmentOrConfig) + ) + ); + } + getProgramAccounts( + programId: PublicKey, + configOrCommitment?: GetProgramAccountsConfig | Commitment + ): Promise< + Array<{ + pubkey: PublicKey; + account: AccountInfo; + }> + > { + return Promise.race( + this.connections.map((c) => + c.getProgramAccounts(programId, configOrCommitment) + ) + ); + } + getRecentBlockhash(commitment?: Commitment): Promise<{ + blockhash: Blockhash; + feeCalculator: FeeCalculator; + }> { + return Promise.race( + this.connections.map((c) => c.getRecentBlockhash(commitment)) + ); + } + getSlot(commitmentOrConfig?: Commitment | GetSlotConfig): Promise { + return Promise.race( + this.connections.map((c) => c.getSlot(commitmentOrConfig)) + ); + } + getTokenAccountBalance( + tokenAddress: PublicKey, + commitment?: Commitment + ): Promise> { + return Promise.race( + this.connections.map((c) => + c.getTokenAccountBalance(tokenAddress, commitment) + ) + ); + } + + getTokenSupply( + tokenMintAddress: PublicKey, + commitment?: Commitment + ): Promise> { + return Promise.race( + this.connections.map((c) => + c.getTokenSupply(tokenMintAddress, commitment) + ) + ); + } + getTransaction( + signature: string, + rawConfig?: GetTransactionConfig + ): Promise { + return Promise.race( + this.connections.map((c) => c.getTransaction(signature, rawConfig)) + ); + } + // Does it make sense to do multiple instances of this? + sendTransaction( + transaction: Transaction, + signers: Array, + options?: SendOptions + ): Promise { + return Promise.race( + this.connections.map((c) => + c.sendTransaction(transaction, signers, options) + ) + ); + } + simulateTransaction( + transactionOrMessage: Transaction | Message, + signers?: Array, + includeAccounts?: boolean | Array + ): Promise> { + return Promise.race( + this.connections.map((c) => + c.simulateTransaction(transactionOrMessage, signers, includeAccounts) + ) + ); + } +} + +// Adds statsd metrics to RPC calls +export class InstrumentedConnection { + connection: SolendRPCConnection; + statsd: StatsD; + prefix: string; + constructor( + connection: SolendRPCConnection, + statsd: StatsD, + prefix: string = "" + ) { + this.connection = connection; + this.statsd = statsd; + this.prefix = prefix; + } + + async getAccountInfo( + publicKey: PublicKey, + commitmentOrConfig?: Commitment | GetAccountInfoConfig + ): Promise | null> { + return this.withStats( + this.connection.getAccountInfo(publicKey, commitmentOrConfig), + "getAccountInfo" + ); + } + async getConfirmedSignaturesForAddress2( + address: PublicKey, + options?: ConfirmedSignaturesForAddress2Options, + commitment?: Finality + ): Promise> { + return this.withStats( + this.connection.getConfirmedSignaturesForAddress2( + address, + options, + commitment + ), + "getConfirmedSignaturesForAddress2" + ); + } + getLatestBlockhash( + commitmentOrConfig?: Commitment | GetLatestBlockhashConfig + ): Promise { + return this.withStats( + this.connection.getLatestBlockhash(commitmentOrConfig), + "getLatestBlockhash" + ); + } + getMultipleAccountsInfo( + publicKeys: PublicKey[], + commitmentOrConfig?: Commitment | GetMultipleAccountsConfig + ): Promise<(AccountInfo | null)[]> { + return this.withStats( + this.connection.getMultipleAccountsInfo(publicKeys, commitmentOrConfig), + "getMultipleAccountsInfo" + ); + } + getProgramAccounts( + programId: PublicKey, + configOrCommitment?: GetProgramAccountsConfig | Commitment + ): Promise< + Array<{ + pubkey: PublicKey; + account: AccountInfo; + }> + > { + return this.withStats( + this.connection.getProgramAccounts(programId, configOrCommitment), + "getProgramAccounts" + ); + } + getRecentBlockhash(commitment?: Commitment): Promise<{ + blockhash: Blockhash; + feeCalculator: FeeCalculator; + }> { + return this.withStats( + this.connection.getRecentBlockhash(commitment), + "getRecentBlockhash" + ); + } + getSlot(commitmentOrConfig?: Commitment | GetSlotConfig): Promise { + return this.withStats( + this.connection.getSlot(commitmentOrConfig), + "getSlot" + ); + } + getTokenAccountBalance( + tokenAddress: PublicKey, + commitment?: Commitment + ): Promise> { + return this.withStats( + this.connection.getTokenAccountBalance(tokenAddress, commitment), + "getTokenAccountBalance" + ); + } + + getTokenSupply( + tokenMintAddress: PublicKey, + commitment?: Commitment + ): Promise> { + return this.withStats( + this.connection.getTokenSupply(tokenMintAddress, commitment), + "getTokenSupply" + ); + } + getTransaction( + signature: string, + rawConfig?: GetTransactionConfig + ): Promise { + return this.withStats( + this.connection.getTransaction(signature, rawConfig), + "getTransaction" + ); + } + sendTransaction( + transaction: Transaction, + signers: Array, + options?: SendOptions + ): Promise { + return this.withStats( + this.connection.sendTransaction(transaction, signers, options), + "sendTransaction" + ); + } + simulateTransaction( + transactionOrMessage: Transaction | Message, + signers?: Array, + includeAccounts?: boolean | Array + ): Promise> { + return this.withStats( + this.connection.simulateTransaction( + transactionOrMessage, + signers, + includeAccounts + ), + "simulateTransaction" + ); + } + + async withStats(fn: Promise, fnName: string) { + this.statsd.increment(this.prefix + "_" + fnName); + const start = Date.now(); + let result; + try { + result = await fn; + } catch (e: any) { + this.statsd.increment(this.prefix + "_" + fnName + "_error"); + throw e; + } + const duration = Date.now() - start; + this.statsd.gauge(this.prefix + "_" + fnName + "_duration", duration); + return result; + } +} + +// Adds retries to RPC Calls +export class RetryConnection { + connection: SolendRPCConnection; + maxRetries: number; + constructor(connection: SolendRPCConnection, maxRetries: number = 3) { + this.connection = connection; + this.maxRetries = maxRetries; + } + + async getAccountInfo( + publicKey: PublicKey, + commitmentOrConfig?: Commitment | GetAccountInfoConfig + ): Promise | null> { + return this.withRetries( + this.connection.getAccountInfo(publicKey, commitmentOrConfig) + ); + } + async getConfirmedSignaturesForAddress2( + address: PublicKey, + options?: ConfirmedSignaturesForAddress2Options, + commitment?: Finality + ): Promise> { + return this.withRetries( + this.connection.getConfirmedSignaturesForAddress2( + address, + options, + commitment + ) + ); + } + getLatestBlockhash( + commitmentOrConfig?: Commitment | GetLatestBlockhashConfig + ): Promise { + return this.withRetries( + this.connection.getLatestBlockhash(commitmentOrConfig) + ); + } + getMultipleAccountsInfo( + publicKeys: PublicKey[], + commitmentOrConfig?: Commitment | GetMultipleAccountsConfig + ): Promise<(AccountInfo | null)[]> { + return this.withRetries( + this.connection.getMultipleAccountsInfo(publicKeys, commitmentOrConfig) + ); + } + getProgramAccounts( + programId: PublicKey, + configOrCommitment?: GetProgramAccountsConfig | Commitment + ): Promise< + Array<{ + pubkey: PublicKey; + account: AccountInfo; + }> + > { + return this.withRetries( + this.connection.getProgramAccounts(programId, configOrCommitment) + ); + } + getRecentBlockhash(commitment?: Commitment): Promise<{ + blockhash: Blockhash; + feeCalculator: FeeCalculator; + }> { + return this.withRetries(this.connection.getRecentBlockhash(commitment)); + } + getSlot(commitmentOrConfig?: Commitment | GetSlotConfig): Promise { + return this.withRetries(this.connection.getSlot(commitmentOrConfig)); + } + getTokenAccountBalance( + tokenAddress: PublicKey, + commitment?: Commitment + ): Promise> { + return this.withRetries( + this.connection.getTokenAccountBalance(tokenAddress, commitment) + ); + } + + getTokenSupply( + tokenMintAddress: PublicKey, + commitment?: Commitment + ): Promise> { + return this.withRetries( + this.connection.getTokenSupply(tokenMintAddress, commitment) + ); + } + getTransaction( + signature: string, + rawConfig?: GetTransactionConfig + ): Promise { + return this.withRetries( + this.connection.getTransaction(signature, rawConfig) + ); + } + sendTransaction( + transaction: Transaction, + signers: Array, + options?: SendOptions + ): Promise { + return this.withRetries( + this.connection.sendTransaction(transaction, signers, options) + ); + } + simulateTransaction( + transactionOrMessage: Transaction | Message, + signers?: Array, + includeAccounts?: boolean | Array + ): Promise> { + return this.withRetries( + this.connection.simulateTransaction( + transactionOrMessage, + signers, + includeAccounts + ) + ); + } + + async withRetries(fn: Promise) { + let numTries = 0; + let lastException; + while (numTries <= this.maxRetries) { + try { + return await fn; + } catch (e: any) { + lastException = e; + numTries += 1; + } + } + throw lastException; + } +} diff --git a/solend-sdk/yarn.lock b/solend-sdk/yarn.lock index 5a5a6489..bd21dad6 100644 --- a/solend-sdk/yarn.lock +++ b/solend-sdk/yarn.lock @@ -1869,7 +1869,7 @@ bignumber.js@^9.0.2: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.2.tgz#71c6c6bed38de64e24a65ebe16cfcf23ae693673" integrity sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw== -bindings@^1.3.0: +bindings@^1.3.0, bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== @@ -3254,6 +3254,13 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +hot-shots@^9.3.0: + version "9.3.0" + resolved "https://registry.yarnpkg.com/hot-shots/-/hot-shots-9.3.0.tgz#1a0b54b81d9efb27af96a971befe343908b0397b" + integrity sha512-e4tgWptiBvlIMnAX0ORe+dNEt0HznD+T2ckzXDUwCBsU7uWr2mwq5UtoT+Df5r9hD5S/DuP8rTxJUQvqAFSFKA== + optionalDependencies: + unix-dgram "2.x" + html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -4535,6 +4542,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.16.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" + integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -5784,6 +5796,14 @@ universalify@^0.1.2: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +unix-dgram@2.x: + version "2.0.6" + resolved "https://registry.yarnpkg.com/unix-dgram/-/unix-dgram-2.0.6.tgz#6d567b0eb6d7a9504e561532b598a46e34c5968b" + integrity sha512-AURroAsb73BZ6CdAyMrTk/hYKNj3DuYYEuOaB8bYMOHGKupRNScw90Q5C71tWJc3uE7dIeXRyuwN0xLLq3vDTg== + dependencies: + bindings "^1.5.0" + nan "^2.16.0" + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"