diff --git a/web3.js/flow-typed/npm/@solana/spl-token_vx.x.x.js b/web3.js/flow-typed/npm/@solana/spl-token_vx.x.x.js new file mode 100644 index 0000000000..f86f65e94a --- /dev/null +++ b/web3.js/flow-typed/npm/@solana/spl-token_vx.x.x.js @@ -0,0 +1,46 @@ +// flow-typed signature: 069c76ac6f0b539483367fd95c435241 +// flow-typed version: <>/@solana/spl-token_v0.0.4/flow_v0.130.0 + +/** + * This is an autogenerated libdef stub for: + * + * '@solana/spl-token' + * + * 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 '@solana/spl-token' { + 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 '@solana/spl-token/lib/index.cjs' { + declare module.exports: any; +} + +declare module '@solana/spl-token/lib/index.esm' { + declare module.exports: any; +} + +declare module '@solana/spl-token/module.flow' { + declare module.exports: any; +} + +// Filename aliases +declare module '@solana/spl-token/lib/index.cjs.js' { + declare module.exports: $Exports<'@solana/spl-token/lib/index.cjs'>; +} +declare module '@solana/spl-token/lib/index.esm.js' { + declare module.exports: $Exports<'@solana/spl-token/lib/index.esm'>; +} +declare module '@solana/spl-token/module.flow.js' { + declare module.exports: $Exports<'@solana/spl-token/module.flow'>; +} diff --git a/web3.js/module.d.ts b/web3.js/module.d.ts index 187c036dfc..e4d06982b0 100644 --- a/web3.js/module.d.ts +++ b/web3.js/module.d.ts @@ -50,6 +50,14 @@ declare module '@solana/web3.js' { skipPreflight?: boolean; }; + export type TokenAccountsFilter = + | { + mint: PublicKey; + } + | { + programId: PublicKey; + }; + export type RpcResponseAndContext = { context: Context; value: T; @@ -150,6 +158,23 @@ declare module '@solana/web3.js' { root: number; }; + export type TokenAccountInfo = { + mint: PublicKey; + owner: PublicKey; + amount: number; + delegate: null | PublicKey; + delegatedAmount: number; + isInitialized: boolean; + isNative: boolean; + }; + + export type TokenAccount = { + executable: boolean; + owner: PublicKey; + lamports: number; + data: TokenAccountInfo; + }; + export type AccountChangeCallback = ( accountInfo: AccountInfo, context: Context, @@ -239,6 +264,21 @@ declare module '@solana/web3.js' { getMinimumLedgerSlot(): Promise; getFirstAvailableBlock(): Promise; getSupply(commitment?: Commitment): Promise>; + getTokenSupply( + tokenMintAddress: PublicKey, + commitment?: Commitment, + ): Promise>; + getTokenAccountBalance( + tokenAddress: PublicKey, + commitment?: Commitment, + ): Promise>; + getTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitment?: Commitment, + ): Promise< + RpcResponseAndContext> + >; getLargestAccounts( config?: GetLargestAccountsConfig, ): Promise>>; diff --git a/web3.js/module.flow.js b/web3.js/module.flow.js index e71bda5e8a..0717b5f749 100644 --- a/web3.js/module.flow.js +++ b/web3.js/module.flow.js @@ -66,6 +66,14 @@ declare module '@solana/web3.js' { skipPreflight: ?boolean, }; + declare export type TokenAccountsFilter = + | { + mint: PublicKey, + } + | { + programId: PublicKey, + }; + declare export type RpcResponseAndContext = { context: Context, value: T, @@ -166,6 +174,23 @@ declare module '@solana/web3.js' { root: number, }; + declare export type TokenAccountInfo = { + mint: PublicKey, + owner: PublicKey, + amount: number, + delegate: null | PublicKey, + delegatedAmount: number, + isInitialized: boolean, + isNative: boolean, + }; + + declare export type TokenAccount = { + executable: boolean, + owner: PublicKey, + lamports: number, + data: TokenAccountInfo, + }; + declare type AccountChangeCallback = ( accountInfo: AccountInfo, context: Context, @@ -255,6 +280,21 @@ declare module '@solana/web3.js' { getMinimumLedgerSlot(): Promise; getFirstAvailableBlock(): Promise; getSupply(commitment: ?Commitment): Promise>; + getTokenSupply( + tokenMintAddress: PublicKey, + commitment: ?Commitment, + ): Promise>; + getTokenAccountBalance( + tokenAddress: PublicKey, + commitment: ?Commitment, + ): Promise>; + getTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitment: ?Commitment, + ): Promise< + RpcResponseAndContext>, + >; getLargestAccounts( config: ?GetLargestAccountsConfig, ): Promise>>; diff --git a/web3.js/package-lock.json b/web3.js/package-lock.json index 25a1af44cb..2825546c92 100644 --- a/web3.js/package-lock.json +++ b/web3.js/package-lock.json @@ -4742,6 +4742,44 @@ "@sinonjs/commons": "^1.7.0" } }, + "@solana/spl-token": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.0.4.tgz", + "integrity": "sha512-zYoZ6iYMKxGYbouunEkWdf6vWRJyEPOkAjvlNVjww9oPKMkIeM9VzgGtjZ/kKMelao1QEohH4JN9qXO4+LwfRA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.5", + "@solana/web3.js": "^0.63.2", + "bn.js": "^5.0.0", + "buffer-layout": "^1.2.0", + "dotenv": "8.2.0", + "json-to-pretty-yaml": "^1.2.2", + "mkdirp-promise": "^5.0.1" + } + }, + "@solana/web3.js": { + "version": "0.63.2", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-0.63.2.tgz", + "integrity": "sha512-4jd8U1U/eFTEemr+jCzQCDepKnkttV4dxWsjMloifb82x1d6KgCzP+Jd6D9kr8f1MFj2i/AnG++97tlHAGTOkA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "bn.js": "^5.0.0", + "bs58": "^4.0.1", + "buffer": "^5.4.3", + "buffer-layout": "^1.2.0", + "crypto-hash": "^1.2.2", + "esdoc-inject-style-plugin": "^1.0.0", + "jayson": "^3.0.1", + "mz": "^2.7.0", + "node-fetch": "^2.2.0", + "npm-run-all": "^4.1.5", + "rpc-websockets": "^5.0.8", + "superstruct": "^0.8.3", + "tweetnacl": "^1.0.0", + "ws": "^7.0.0" + } + }, "@szmarczak/http-timer": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", @@ -8274,6 +8312,12 @@ "is-obj": "^1.0.0" } }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", + "dev": true + }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -14170,6 +14214,16 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, + "json-to-pretty-yaml": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz", + "integrity": "sha1-9M0L0KXo/h3yWq9boRiwmf2ZLVs=", + "dev": true, + "requires": { + "remedial": "^1.0.7", + "remove-trailing-spaces": "^1.0.6" + } + }, "json5": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", @@ -15150,6 +15204,15 @@ "minimist": "0.0.8" } }, + "mkdirp-promise": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mkdirp-promise/-/mkdirp-promise-5.0.1.tgz", + "integrity": "sha1-6bj2jlUsaKnBcTuEiD96HdA5uKE=", + "dev": true, + "requires": { + "mkdirp": "*" + } + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -20096,12 +20159,24 @@ } } }, + "remedial": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz", + "integrity": "sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==", + "dev": true + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", "dev": true }, + "remove-trailing-spaces": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.7.tgz", + "integrity": "sha512-wjM17CJ2kk0SgoGyJ7ZMzRRCuTq+V8YhMwpZ5XEWX0uaked2OUq6utvHXGNBQrfkUzUUABFMyxlKn+85hMv4dg==", + "dev": true + }, "repeat-element": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", diff --git a/web3.js/package.json b/web3.js/package.json index cedc91a2b8..3508e66ce8 100644 --- a/web3.js/package.json +++ b/web3.js/package.json @@ -97,6 +97,7 @@ "@babel/preset-flow": "^7.0.0", "@commitlint/config-conventional": "^9.0.1", "@commitlint/travis-cli": "^9.0.1", + "@solana/spl-token": "^0.0.4", "@typescript-eslint/eslint-plugin": "^2.18.0", "@typescript-eslint/parser": "^2.18.0", "acorn": "^7.0.0", diff --git a/web3.js/src/connection.js b/web3.js/src/connection.js index 3a21be55f4..b57807c7ac 100644 --- a/web3.js/src/connection.js +++ b/web3.js/src/connection.js @@ -24,6 +24,14 @@ export const BLOCKHASH_CACHE_TIMEOUT_MS = 30 * 1000; type RpcRequest = (methodName: string, args: Array) => any; +type TokenAccountsFilter = + | { + mint: PublicKey, + } + | { + programId: PublicKey, + }; + /** * Extra contextual information for RPC responses * @@ -500,6 +508,77 @@ const GetSupplyRpcResult = jsonRpcResultAndContext( }), ); +/** + * Information describing a token account + */ +type TokenAccountInfo = {| + mint: PublicKey, + owner: PublicKey, + amount: number, + delegate: null | PublicKey, + delegatedAmount: number, + isInitialized: boolean, + isNative: boolean, +|}; + +/** + * Information describing an account with token account data + * + * @typedef {Object} TokenAccount + * @property {number} lamports Number of lamports assigned to the account + * @property {PublicKey} owner Identifier of the program that owns the account + * @property {TokenAccountInfo} data Token account data + * @property {boolean} executable `true` if this account's data contains a loaded program + */ +type TokenAccount = { + executable: boolean, + owner: PublicKey, + lamports: number, + data: TokenAccountInfo, +}; + +const TokenAccountResult = struct({ + token: struct({ + account: struct({ + mint: 'string', + owner: 'string', + amount: 'number', + delegate: struct.union(['string', 'null']), + delegatedAmount: 'number', + isInitialized: 'boolean', + isNative: 'boolean', + }), + }), +}); + +/** + * Expected JSON RPC response for the "getTokenAccountBalance" message + */ +const GetTokenAccountBalance = jsonRpcResultAndContext('number'); + +/** + * Expected JSON RPC response for the "getTokenSupply" message + */ +const GetTokenSupplyRpcResult = jsonRpcResultAndContext('number'); + +/** + * Expected JSON RPC response for the "getTokenAccountsByOwner" message + */ +const GetTokenAccountsByOwner = jsonRpcResultAndContext( + struct.array([ + struct({ + pubkey: 'string', + account: struct({ + executable: 'boolean', + owner: 'string', + lamports: 'number', + data: TokenAccountResult, + rentEpoch: 'number?', + }), + }), + ]), +); + /** * Pair of an account address and its balance * @@ -541,7 +620,7 @@ const AccountInfoResult = struct({ executable: 'boolean', owner: 'string', lamports: 'number', - data: 'string', + data: 'any', rentEpoch: 'number?', }); @@ -1185,6 +1264,109 @@ export class Connection { return res.result; } + /** + * Fetch the current supply of a token mint + */ + async getTokenSupply( + tokenMintAddress: PublicKey, + commitment: ?Commitment, + ): Promise> { + const args = this._argsWithCommitment( + [tokenMintAddress.toBase58()], + commitment, + ); + const unsafeRes = await this._rpcRequest('getTokenSupply', args); + const res = GetTokenSupplyRpcResult(unsafeRes); + if (res.error) { + throw new Error('failed to get token supply: ' + res.error.message); + } + assert(typeof res.result !== 'undefined'); + return res.result; + } + + /** + * Fetch the current balance of a token account + */ + async getTokenAccountBalance( + tokenAddress: PublicKey, + commitment: ?Commitment, + ): Promise> { + const args = this._argsWithCommitment( + [tokenAddress.toBase58()], + commitment, + ); + const unsafeRes = await this._rpcRequest('getTokenAccountBalance', args); + const res = GetTokenAccountBalance(unsafeRes); + if (res.error) { + throw new Error( + 'failed to get token account balance: ' + res.error.message, + ); + } + assert(typeof res.result !== 'undefined'); + return res.result; + } + + /** + * Fetch all the token accounts owned by the specified account + * + * @return {Promise>>} + */ + async getTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitment: ?Commitment, + ): Promise< + RpcResponseAndContext>, + > { + let _args = [ownerAddress.toBase58()]; + + // Strip flow types to make flow happy + ((filter: any) => { + if ('mint' in filter) { + _args.push({mint: filter.mint.toBase58()}); + } else { + _args.push({programId: filter.programId.toBase58()}); + } + })(filter); + + const args = this._argsWithCommitment(_args, commitment); + const unsafeRes = await this._rpcRequest('getTokenAccountsByOwner', args); + const res = GetTokenAccountsByOwner(unsafeRes); + if (res.error) { + throw new Error( + 'failed to get token accounts owned by account ' + + ownerAddress.toBase58() + + ': ' + + res.error.message, + ); + } + + const {result} = res; + const {context, value} = result; + assert(typeof result !== 'undefined'); + + return { + context, + value: value.map(result => { + const data = result.account.data.token.account; + return { + pubkey: new PublicKey(result.pubkey), + account: { + executable: result.account.executable, + owner: new PublicKey(result.account.owner), + lamports: result.account.lamports, + data: { + ...data, + mint: new PublicKey(data.mint), + owner: new PublicKey(data.owner), + delegate: data.delegate ? new PublicKey(data.delegate) : null, + }, + }, + }; + }), + }; + } + /** * Fetch the 20 largest accounts with their current balances */ diff --git a/web3.js/test/connection.test.js b/web3.js/test/connection.test.js index f4a777521b..7dc4fafadc 100644 --- a/web3.js/test/connection.test.js +++ b/web3.js/test/connection.test.js @@ -1,5 +1,6 @@ // @flow import bs58 from 'bs58'; +import {Token, u64} from '@solana/spl-token'; import { Account, @@ -1261,6 +1262,129 @@ test('get supply', async () => { expect(supply.nonCirculatingAccounts.length).toBeGreaterThan(0); }); +const TOKEN_PROGRAM_ID = new PublicKey( + 'TokenSVp5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o', +); + +test('token methods', async () => { + if (mockRpcEnabled) { + console.log('non-live test skipped'); + return; + } + + const connection = new Connection(url); + const payerAccount = new Account(); + await connection.confirmTransaction( + await connection.requestAirdrop(payerAccount.publicKey, 100000000000), + 0, + ); + + const mintOwner = new Account(); + const accountOwner = new Account(); + const [token, tokenAccount] = await Token.createMint( + connection, + payerAccount, + mintOwner.publicKey, + accountOwner.publicKey, + new u64(10000), + 2, + TOKEN_PROGRAM_ID, + false, + ); + + await Token.createMint( + connection, + payerAccount, + mintOwner.publicKey, + accountOwner.publicKey, + new u64(10000), + 2, + TOKEN_PROGRAM_ID, + false, + ); + + const tokenAccountDest = await token.createAccount(accountOwner.publicKey); + await token.transfer( + tokenAccount, + tokenAccountDest, + accountOwner, + [], + new u64(1), + ); + + const supply = (await connection.getTokenSupply(token.publicKey, 'recent')) + .value; + expect(supply).toEqual(10000); + + const newAccount = new Account(); + await expect( + connection.getTokenSupply(newAccount.publicKey, 'recent'), + ).rejects.toThrow(); + + const balance = ( + await connection.getTokenAccountBalance(tokenAccount, 'recent') + ).value; + expect(balance).toEqual(9999); + + await expect( + connection.getTokenAccountBalance(newAccount.publicKey, 'recent'), + ).rejects.toThrow(); + + const accountsWithMintFilter = ( + await connection.getTokenAccountsByOwner( + accountOwner.publicKey, + {mint: token.publicKey}, + 'recent', + ) + ).value; + expect(accountsWithMintFilter.length).toEqual(2); + for (const {account} of accountsWithMintFilter) { + expect(account.data.mint.toBase58()).toEqual(token.publicKey.toBase58()); + expect(account.data.owner.toBase58()).toEqual( + accountOwner.publicKey.toBase58(), + ); + } + + const accountsWithProgramFilter = ( + await connection.getTokenAccountsByOwner( + accountOwner.publicKey, + {programId: TOKEN_PROGRAM_ID}, + 'recent', + ) + ).value; + expect(accountsWithProgramFilter.length).toEqual(3); + for (const {account} of accountsWithProgramFilter) { + expect(account.data.owner.toBase58()).toEqual( + accountOwner.publicKey.toBase58(), + ); + } + + const noAccounts = ( + await connection.getTokenAccountsByOwner( + newAccount.publicKey, + {mint: token.publicKey}, + 'recent', + ) + ).value; + expect(noAccounts.length).toEqual(0); + + await expect( + connection.getTokenAccountsByOwner( + accountOwner.publicKey, + {mint: newAccount.publicKey}, + 'recent', + ), + ).rejects.toThrow(); + + await expect( + connection.getTokenAccountsByOwner( + accountOwner.publicKey, + {programId: newAccount.publicKey}, + 'recent', + ), + ).rejects.toThrow(); +}); + test('get largest accounts', async () => { const connection = new Connection(url);