diff --git a/web3.js/.gitignore b/web3.js/.gitignore index dfae3cb105..ddbb58d163 100644 --- a/web3.js/.gitignore +++ b/web3.js/.gitignore @@ -23,4 +23,4 @@ lib doc # VIM swap files -*.sw. +*.sw* diff --git a/web3.js/flow-typed/mz_vx.x.x.js b/web3.js/flow-typed/mz.js similarity index 100% rename from web3.js/flow-typed/mz_vx.x.x.js rename to web3.js/flow-typed/mz.js diff --git a/web3.js/flow-typed/rpc-websockets.js b/web3.js/flow-typed/rpc-websockets.js new file mode 100644 index 0000000000..a26d5fa785 --- /dev/null +++ b/web3.js/flow-typed/rpc-websockets.js @@ -0,0 +1,88 @@ +// flow-typed signature: 31b3dc13d06052ea505e8da9ca72c537 +// flow-typed version: <>/rpc-websockets_v4.3.3/flow_v0.84.0 + +/** + * This is an autogenerated libdef stub for: + * + * 'rpc-websockets' + * + * Fill this stub out by replacing all the `any` types. + * + * Once filled out, we encourage you to share your work with the + * community by sending a pull request to: + * https://github.com/flowtype/flow-typed + */ + +declare module 'rpc-websockets' { + declare module.exports: any; +} + +/** + * We include stubs for each file inside this npm package in case you need to + * require those files directly. Feel free to delete any files that aren't + * needed. + */ +declare module 'rpc-websockets/dist/index.browser-bundle' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/index.browser' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/index' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/lib/client' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/lib/client/websocket.browser' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/lib/client/websocket' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/lib/handler' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/lib/server' { + declare module.exports: any; +} + +declare module 'rpc-websockets/dist/lib/utils' { + declare module.exports: any; +} + +// Filename aliases +declare module 'rpc-websockets/dist/index.browser-bundle.js' { + declare module.exports: $Exports<'rpc-websockets/dist/index.browser-bundle'>; +} +declare module 'rpc-websockets/dist/index.browser.js' { + declare module.exports: $Exports<'rpc-websockets/dist/index.browser'>; +} +declare module 'rpc-websockets/dist/index.js' { + declare module.exports: $Exports<'rpc-websockets/dist/index'>; +} +declare module 'rpc-websockets/dist/lib/client.js' { + declare module.exports: $Exports<'rpc-websockets/dist/lib/client'>; +} +declare module 'rpc-websockets/dist/lib/client/websocket.browser.js' { + declare module.exports: $Exports<'rpc-websockets/dist/lib/client/websocket.browser'>; +} +declare module 'rpc-websockets/dist/lib/client/websocket.js' { + declare module.exports: $Exports<'rpc-websockets/dist/lib/client/websocket'>; +} +declare module 'rpc-websockets/dist/lib/handler.js' { + declare module.exports: $Exports<'rpc-websockets/dist/lib/handler'>; +} +declare module 'rpc-websockets/dist/lib/server.js' { + declare module.exports: $Exports<'rpc-websockets/dist/lib/server'>; +} +declare module 'rpc-websockets/dist/lib/utils.js' { + declare module.exports: $Exports<'rpc-websockets/dist/lib/utils'>; +} diff --git a/web3.js/module.flow.js b/web3.js/module.flow.js index 42c6580bf7..1b3f1df670 100644 --- a/web3.js/module.flow.js +++ b/web3.js/module.flow.js @@ -41,6 +41,8 @@ declare module '@solana/web3.js' { userdata: Buffer, } + declare type AccountChangeCallback = (accountInfo: AccountInfo) => void; + declare export type SignatureStatus = 'Confirmed' | 'AccountInUse' | 'SignatureNotFound' @@ -58,6 +60,8 @@ declare module '@solana/web3.js' { getFinality(): Promise; requestAirdrop(to: PublicKey, amount: number): Promise; sendTransaction(from: Account, transaction: Transaction): Promise; + onAccountChange(publickey: PublicKey, callback: AccountChangeCallback): Promise; + removeAccountListener(id: number): Promise; } // === src/system-program.js === diff --git a/web3.js/package-lock.json b/web3.js/package-lock.json index f6aab7d907..8773ee575c 100644 --- a/web3.js/package-lock.json +++ b/web3.js/package-lock.json @@ -4,6 +4,23 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "101": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/101/-/101-1.6.3.tgz", + "integrity": "sha512-4dmQ45yY0Dx24Qxp+zAsNLlMF6tteCyfVzgbulvSyC7tCyd3V8sW76sS0tHq8NpcbXfWTKasfyfzU1Kd86oKzw==", + "requires": { + "clone": "1.0.4", + "deep-eql": "0.1.3", + "keypather": "1.10.2" + }, + "dependencies": { + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + } + } + }, "@babel/code-frame": { "version": "7.0.0-beta.44", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0-beta.44.tgz", @@ -917,6 +934,19 @@ "minimalistic-assert": "1.0.1" } }, + "assert-args": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/assert-args/-/assert-args-1.2.1.tgz", + "integrity": "sha1-QEEDoUUqMv53iYgR5U5ZCoqTc70=", + "requires": { + "101": "1.6.3", + "compound-subject": "0.0.1", + "debug": "2.6.9", + "get-prototype-of": "0.0.0", + "is-capitalized": "1.0.0", + "is-class": "0.0.4" + } + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -947,8 +977,7 @@ "async-limiter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", - "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==", - "dev": true + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" }, "asynckit": { "version": "0.4.0", @@ -2964,6 +2993,11 @@ "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", "dev": true }, + "compound-subject": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/compound-subject/-/compound-subject-0.0.1.tgz", + "integrity": "sha1-JxVUaYoVrmCLHfyv0wt7oeqJLEs=" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3277,7 +3311,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "requires": { "ms": "2.0.0" } @@ -3321,6 +3354,14 @@ "mimic-response": "1.0.1" } }, + "deep-eql": { + "version": "0.1.3", + "resolved": "http://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", + "requires": { + "type-detect": "0.1.1" + } + }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4631,6 +4672,11 @@ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, + "eventemitter3": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz", + "integrity": "sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA==" + }, "evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -6077,6 +6123,11 @@ "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=", "dev": true }, + "get-prototype-of": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/get-prototype-of/-/get-prototype-of-0.0.0.tgz", + "integrity": "sha1-mHcr0QcW0W3rSzIlFsRp78oorEQ=" + }, "get-stdin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", @@ -6866,6 +6917,11 @@ "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=", "dev": true }, + "is-capitalized": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-capitalized/-/is-capitalized-1.0.0.tgz", + "integrity": "sha1-TIRktNkdPk7rRIid0s2PGwrEwTY=" + }, "is-ci": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.1.0.tgz", @@ -6875,6 +6931,11 @@ "ci-info": "1.1.3" } }, + "is-class": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/is-class/-/is-class-0.0.4.tgz", + "integrity": "sha1-4FdFFwW7NOOePjNZjJOpg3KWtzY=" + }, "is-data-descriptor": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", @@ -8085,6 +8146,16 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", "dev": true + }, + "ws": { + "version": "4.1.0", + "resolved": "http://registry.npmjs.org/ws/-/ws-4.1.0.tgz", + "integrity": "sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA==", + "dev": true, + "requires": { + "async-limiter": "1.0.0", + "safe-buffer": "5.1.1" + } } } }, @@ -8155,6 +8226,14 @@ "verror": "1.10.0" } }, + "keypather": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/keypather/-/keypather-1.10.2.tgz", + "integrity": "sha1-4ESWMtSz5RbyHMAUznxWRP3c5hQ=", + "requires": { + "101": "1.6.3" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -9046,6 +9125,15 @@ "minimist": "0.0.8" } }, + "mock-socket": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-8.0.4.tgz", + "integrity": "sha512-xuO+Ep0xI60sXfBwIXezHuBnSj3Rafh6TSeTH81k2aFuNf64JW46PeDaViWMXQ/nMInknUzfMriPkoVhmh5Aag==", + "dev": true, + "requires": { + "url-parse": "1.4.3" + } + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -9055,8 +9143,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "mute-stream": { "version": "0.0.7", @@ -13001,6 +13088,12 @@ "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==", "dev": true }, + "querystringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.0.tgz", + "integrity": "sha512-sluvZZ1YiTLD5jsqZcDmFyV2EwToyXZBfpoVOmktMmW+VEnhgakFHnasVph65fOjGPTWN0Nw3+XQaSeMayr0kg==", + "dev": true + }, "quick-lru": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", @@ -13376,6 +13469,12 @@ "resolve-from": "1.0.1" } }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true + }, "reselect": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", @@ -13690,6 +13789,39 @@ "minimatch": "3.0.4" } }, + "rpc-websockets": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-4.3.3.tgz", + "integrity": "sha512-Pq+tubbPkY63e0b1jukKkEQNWa5twpSvdd5izvtAK8OAw4qGGbWzJytqOdtProzpZQhFzynaHLNwfocmx8tmbg==", + "requires": { + "assert-args": "1.2.1", + "babel-runtime": "6.26.0", + "circular-json": "0.5.9", + "eventemitter3": "3.1.0", + "uuid": "3.3.2", + "ws": "5.2.2" + }, + "dependencies": { + "circular-json": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.5.9.tgz", + "integrity": "sha512-4ivwqHpIFJZBuhN3g/pEcdbnGUywkBblloGbkglyloVjjR3uT6tieI89MVOfbP2tHX5sgb01FuLgAOzebNlJNQ==" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "requires": { + "async-limiter": "1.0.0" + } + } + } + }, "rst-selector-parser": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", @@ -15511,6 +15643,11 @@ "prelude-ls": "1.1.2" } }, + "type-detect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", + "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -15758,6 +15895,16 @@ "integrity": "sha1-TTNA6AfTdzvamZH4MFrNzCpmXSo=", "dev": true }, + "url-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.3.tgz", + "integrity": "sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw==", + "dev": true, + "requires": { + "querystringify": "2.1.0", + "requires-port": "1.0.0" + } + }, "url-parse-lax": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz", @@ -16041,13 +16188,11 @@ } }, "ws": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-4.1.0.tgz", - "integrity": "sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA==", - "dev": true, + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.0.tgz", + "integrity": "sha512-H3dGVdGvW2H8bnYpIDc3u3LH8Wue3Qh+Zto6aXXFzvESkTVT6rAfKR6tR/+coaUvxs8yHtmNV0uioBF62ZGSTg==", "requires": { - "async-limiter": "1.0.0", - "safe-buffer": "5.1.1" + "async-limiter": "1.0.0" } }, "xml-name-validator": { diff --git a/web3.js/package.json b/web3.js/package.json index 9b5eedc5a5..894ddc47df 100644 --- a/web3.js/package.json +++ b/web3.js/package.json @@ -59,8 +59,10 @@ "jayson": "^2.0.6", "mz": "^2.7.0", "node-fetch": "^2.2.0", + "rpc-websockets": "^4.3.3", "superstruct": "^0.6.0", - "tweetnacl": "^1.0.0" + "tweetnacl": "^1.0.0", + "ws": "^6.1.0" }, "devDependencies": { "babel-core": "6.26.3", diff --git a/web3.js/rollup.config.js b/web3.js/rollup.config.js index 21fe2829d6..8966ed3136 100644 --- a/web3.js/rollup.config.js +++ b/web3.js/rollup.config.js @@ -70,18 +70,31 @@ function generateConfig(configType) { // maintained. config.external = [ 'assert', + 'babel-runtime/core-js/get-iterator', + 'babel-runtime/core-js/json/stringify', + 'babel-runtime/core-js/object/assign', + 'babel-runtime/core-js/object/get-prototype-of', + 'babel-runtime/core-js/object/keys', 'babel-runtime/core-js/promise', 'babel-runtime/helpers/asyncToGenerator', 'babel-runtime/helpers/classCallCheck', 'babel-runtime/helpers/createClass', + 'babel-runtime/helpers/get', + 'babel-runtime/helpers/inherits', + 'babel-runtime/helpers/possibleConstructorReturn', 'babel-runtime/helpers/toConsumableArray', 'babel-runtime/helpers/typeof', 'babel-runtime/regenerator', + 'bn.js', 'bs58', + 'buffer-layout', + 'elfy', 'jayson/lib/client/browser', 'node-fetch', + 'rpc-websockets', 'superstruct', 'tweetnacl', + 'url', ]; break; default: diff --git a/web3.js/src/connection.js b/web3.js/src/connection.js index b3b50eaf7d..fe13eef1c5 100644 --- a/web3.js/src/connection.js +++ b/web3.js/src/connection.js @@ -1,9 +1,14 @@ // @flow import assert from 'assert'; +import { + parse as urlParse, + format as urlFormat, +} from 'url'; import fetch from 'node-fetch'; import jayson from 'jayson/lib/client/browser'; import {struct} from 'superstruct'; +import {Client as RpcWebSocketClient} from 'rpc-websockets'; import {Transaction} from './transaction'; import {PublicKey} from './publickey'; @@ -11,6 +16,7 @@ import {sleep} from './util/sleep'; import type {Account} from './account'; import type {TransactionSignature, TransactionId} from './transaction'; + type RpcRequest = (methodName: string, args: Array) => any; function createRpcRequest(url): RpcRequest { @@ -79,11 +85,10 @@ function jsonRpcResult(resultDescription: any) { ]); } - /** - * Expected JSON RPC response for the "getAccountInfo" message + * @private */ -const GetAccountInfoRpcResult = jsonRpcResult({ +const AccountInfoResult = struct({ executable: 'boolean', loader_program_id: 'array', program_id: 'array', @@ -91,6 +96,18 @@ const GetAccountInfoRpcResult = jsonRpcResult({ userdata: 'array', }); +/** + * Expected JSON RPC response for the "getAccountInfo" message + */ +const GetAccountInfoRpcResult = jsonRpcResult(AccountInfoResult); + +/*** + * Expected JSON RPC response for the "accountNotification" message + */ +const AccountNotificationResult = struct({ + subscription: 'number', + result: AccountInfoResult, +}); /** * Expected JSON RPC response for the "confirmTransaction" message @@ -148,6 +165,20 @@ type AccountInfo = { userdata: Buffer, } +/** + * Callback function for account change notifications + */ +export type AccountChangeCallback = (accountInfo: AccountInfo) => void; + +/** + * @private + */ +type AccountSubscriptionInfo = { + publicKey: string; // PublicKey of the account as a base 58 string + callback: AccountChangeCallback, + subscriptionId: null | number; // null when there's no current server subscription id +} + /** * Possible signature status values * @@ -164,6 +195,8 @@ export type SignatureStatus = 'Confirmed' */ export class Connection { _rpcRequest: RpcRequest; + _rpcWebSocket: RpcWebSocketClient; + _rpcWebSocketConnected: boolean = false; _lastIdInfo: { lastId: TransactionId | null, @@ -171,6 +204,8 @@ export class Connection { transactionSignatures: Array, }; _disableLastIdCaching: boolean = false + _accountChangeSubscriptions: {[number]: AccountSubscriptionInfo} = {}; + _accountChangeSubscriptionCounter: number = 0; /** * Establish a JSON RPC connection @@ -178,15 +213,29 @@ export class Connection { * @param endpoint URL to the fullnode JSON RPC endpoint */ constructor(endpoint: string) { - if (typeof endpoint !== 'string') { - throw new Error('Connection endpoint not specified'); - } - this._rpcRequest = createRpcRequest(endpoint); + let url = urlParse(endpoint); + + this._rpcRequest = createRpcRequest(url.href); this._lastIdInfo = { lastId: null, seconds: -1, transactionSignatures: [], }; + + url.protocol = 'ws'; + url.host = ''; + url.port = String(Number(url.port) + 1); + this._rpcWebSocket = new RpcWebSocketClient( + urlFormat(url), + { + autoconnect: false, + max_reconnects: Infinity, + } + ); + this._rpcWebSocket.on('open', this._wsOnOpen.bind(this)); + this._rpcWebSocket.on('error', this._wsOnError.bind(this)); + this._rpcWebSocket.on('close', this._wsOnClose.bind(this)); + this._rpcWebSocket.on('accountNotification', this._wsOnAccountNotification.bind(this)); } /** @@ -371,4 +420,133 @@ export class Connection { assert(res.result); return res.result; } + + /** + * @private + */ + _wsOnOpen() { + this._rpcWebSocketConnected = true; + this._updateSubscriptions(); + } + + /** + * @private + */ + _wsOnError(err: Error) { + console.log('ws error:', err.message); + } + + /** + * @private + */ + _wsOnClose(code: number, message: string) { + // 1000 means _rpcWebSocket.close() was called explicitly + if (code !== 1000) { + console.log('ws close:', code, message); + } + this._rpcWebSocketConnected = false; + } + + /** + * @private + */ + _wsOnAccountNotification(notification: Object) { + const res = AccountNotificationResult(notification); + if (res.error) { + throw new Error(res.error.message); + } + + const keys = Object.keys(this._accountChangeSubscriptions).map(Number); + for (let id of keys) { + const sub = this._accountChangeSubscriptions[id]; + if (sub.subscriptionId === res.subscription) { + const {result} = res; + assert(typeof result !== 'undefined'); + + sub.callback({ + executable: result.executable, + tokens: result.tokens, + programId: new PublicKey(result.program_id), + loaderProgramId: new PublicKey(result.loader_program_id), + userdata: Buffer.from(result.userdata), + }); + return true; + } + } + } + + /** + * @private + */ + async _updateSubscriptions() { + const keys = Object.keys(this._accountChangeSubscriptions).map(Number); + if (keys.length === 0) { + this._rpcWebSocket.close(); + return; + } + + if (!this._rpcWebSocketConnected) { + for (let id of keys) { + this._accountChangeSubscriptions[id].subscriptionId = null; + } + this._rpcWebSocket.connect(); + return; + } + + for (let id of keys) { + const {subscriptionId, publicKey} = this._accountChangeSubscriptions[id]; + if (subscriptionId === null) { + try { + this._accountChangeSubscriptions[id].subscriptionId = + await this._rpcWebSocket.call( + 'accountSubscribe', + [publicKey] + ); + } catch (err) { + console.log(`accountSubscribe error for ${publicKey}: ${err.message}`); + } + } + } + } + + /** + * Register a callback to be invoked whenever the specified account changes + * + * @param publickey Public key of the account to monitor + * @param callback Function to invoke whenever the account is changed + * @return subscription id + */ + onAccountChange(publicKey: PublicKey, callback: AccountChangeCallback): number { + const id = ++this._accountChangeSubscriptionCounter; + this._accountChangeSubscriptions[id] = { + publicKey: publicKey.toBase58(), + callback, + subscriptionId: null + }; + this._updateSubscriptions(); + return id; + } + + /** + * Deregister an account notification callback + * + * @param id subscription id to deregister + */ + async removeAccountChangeListener(id: number): Promise { + if (this._accountChangeSubscriptions[id]) { + const {subscriptionId} = this._accountChangeSubscriptions[id]; + delete this._accountChangeSubscriptions[id]; + if (subscriptionId !== null) { + try { + await this._rpcWebSocket.call('accountUnsubscribe', [subscriptionId]); + } catch (err) { + console.log('accountUnsubscribe error:', err.message); + } + } + this._updateSubscriptions(); + } else { + throw new Error(`Unknown account change id: ${id}`); + } + } + } diff --git a/web3.js/test/__mocks__/node-fetch.js b/web3.js/test/__mocks__/node-fetch.js index d26c1454db..1dd6fff568 100644 --- a/web3.js/test/__mocks__/node-fetch.js +++ b/web3.js/test/__mocks__/node-fetch.js @@ -22,12 +22,17 @@ export const mockRpc: Array<[string, RpcRequest, RpcResponse]> = []; // identified by `url` instead of using the mock export const mockRpcEnabled = !process.env.DOITLIVE; +let mockNotice = true; + // Suppress lint: 'JestMockFn' is not defined // eslint-disable-next-line no-undef const mock: JestMockFn = jest.fn( (fetchUrl, fetchOptions) => { if (!mockRpcEnabled) { - console.log(`Note: node-fetch mock is disabled, testing live against ${fetchUrl}`); + if (mockNotice) { + console.log(`Note: node-fetch mock is disabled, testing live against ${fetchUrl}`); + mockNotice = false; + } return fetch(fetchUrl, fetchOptions); } diff --git a/web3.js/test/__mocks__/rpc-websockets.js b/web3.js/test/__mocks__/rpc-websockets.js new file mode 100644 index 0000000000..44e99c90ff --- /dev/null +++ b/web3.js/test/__mocks__/rpc-websockets.js @@ -0,0 +1,48 @@ +import {Client as RpcWebSocketClient} from 'rpc-websockets'; + +// Define DOITLIVE in the environment to test against the real full node +// identified by `url` instead of using the mock +export const mockRpcEnabled = !process.env.DOITLIVE; + +let mockNotice = true; + +export class Client { + client: RpcWebSocketClient; + + constructor(url, options) { + //console.log('MockClient', url, options); + if (!mockRpcEnabled) { + if (mockNotice) { + console.log('Note: rpc-websockets mock is disabled, testing live against', url); + mockNotice = false; + } + this.client = new RpcWebSocketClient(url, options); + } + } + + connect() { + if (!mockRpcEnabled) { + return this.client.connect(); + } + } + + close() { + if (!mockRpcEnabled) { + return this.client.close(); + } + } + + on(event: string, callback: Function) { + if (!mockRpcEnabled) { + return this.client.on(event, callback); + } + //console.log('on', event); + } + + async call(method: string, params: Object): Promise { + if (!mockRpcEnabled) { + return await this.client.call(method, params); + } + throw new Error('call unsupported'); + } +} diff --git a/web3.js/test/connection.test.js b/web3.js/test/connection.test.js index 7f94eed553..49baa8c3fb 100644 --- a/web3.js/test/connection.test.js +++ b/web3.js/test/connection.test.js @@ -1,9 +1,11 @@ // @flow - import { Account, Connection, + BpfLoader, + Loader, SystemProgram, + sendAndConfirmTransaction, } from '../src'; import {mockRpc, mockRpcEnabled} from './__mocks__/node-fetch'; import {mockGetLastId} from './mockrpc/getlastid'; @@ -410,3 +412,46 @@ test('multi-instruction transaction', async () => { expect(await connection.getBalance(accountTo.publicKey)).toBe(21); }); + +test('account change notification', async () => { + if (mockRpcEnabled) { + console.log('non-live test skipped'); + return; + } + + const connection = new Connection(url); + const owner = new Account(); + const programAccount = new Account(); + + const mockCallback = jest.fn(); + + const subscriptionId = connection.onAccountChange(programAccount.publicKey, mockCallback); + + await connection.requestAirdrop(owner.publicKey, 42); + const transaction = SystemProgram.createAccount( + owner.publicKey, + programAccount.publicKey, + 42, + 3, + BpfLoader.programId, + ); + await sendAndConfirmTransaction(connection, owner, transaction); + + const loader = new Loader(connection, BpfLoader.programId); + await loader.load(programAccount, [1, 2, 3]); + + await connection.removeAccountChangeListener(subscriptionId); + + // mockCallback should be called twice + expect(mockCallback.mock.calls).toHaveLength(2); + + // First mockCallback call is due to SystemProgram.createAccount() + expect(mockCallback.mock.calls[0][0].tokens).toBe(42); + expect(mockCallback.mock.calls[0][0].executable).toBe(false); + expect(mockCallback.mock.calls[0][0].userdata).toEqual(Buffer.from([0, 0, 0])); + expect(mockCallback.mock.calls[0][0].programId).toEqual(BpfLoader.programId); + + // Second mockCallback call is due to loader.load() + expect(mockCallback.mock.calls[1][0].userdata).toEqual(Buffer.from([1, 2, 3])); +}); + diff --git a/web3.js/test/url.js b/web3.js/test/url.js index 823e6e8043..1b2919afea 100644 --- a/web3.js/test/url.js +++ b/web3.js/test/url.js @@ -3,7 +3,6 @@ /** * The connection url to use when running unit tests against a live network */ -export const url = 'http://localhost:8899'; -//export const url = 'http://testnet.solana.com:8899'; -//export const url = 'http://master.testnet.solana.com:8899'; +export const url = 'http://localhost:8899/'; +//export const url = 'http://testnet.solana.com:8899/';