diff --git a/web3.js/module.flow.js b/web3.js/module.flow.js index 2965e3eebb..02827d0fe1 100644 --- a/web3.js/module.flow.js +++ b/web3.js/module.flow.js @@ -92,4 +92,8 @@ declare module '@solana/web3.js' { sign(from: Account): void; serialize(): Buffer; } + + // === src/token-program.js === + /* TODO */ + } diff --git a/web3.js/src/connection.js b/web3.js/src/connection.js index af8cf21e3c..d37f0d0b25 100644 --- a/web3.js/src/connection.js +++ b/web3.js/src/connection.js @@ -148,7 +148,7 @@ type AccountInfo = { * * @typedef {string} SignatureStatus */ -type SignatureStatus = 'Confirmed' | 'SignatureNotFound' | 'ProgramRuntimeError' | 'GenericFailure'; +export type SignatureStatus = 'Confirmed' | 'SignatureNotFound' | 'ProgramRuntimeError' | 'GenericFailure'; /** * A connection to a fullnode JSON RPC endpoint diff --git a/web3.js/src/index.js b/web3.js/src/index.js index 506a72cc38..05ab8d8ca4 100644 --- a/web3.js/src/index.js +++ b/web3.js/src/index.js @@ -5,3 +5,4 @@ export {Connection} from './connection'; export {PublicKey} from './publickey'; export {SystemProgram} from './system-program'; export {Transaction} from './transaction'; +export {Token} from './token-program'; diff --git a/web3.js/src/layout.js b/web3.js/src/layout.js index e5ccce8134..35c74de5e1 100644 --- a/web3.js/src/layout.js +++ b/web3.js/src/layout.js @@ -11,8 +11,8 @@ export const publicKey = (property: string = 'publicKey'): Object => { }; /** - * Layout for a 256bit unsigned value + * Layout for a 64bit unsigned value */ -export const uint256 = (property: string = 'uint256'): Object => { - return BufferLayout.blob(32, property); +export const uint64 = (property: string = 'uint64'): Object => { + return BufferLayout.blob(8, property); }; diff --git a/web3.js/src/publickey.js b/web3.js/src/publickey.js index a37b3560f1..82c800159b 100644 --- a/web3.js/src/publickey.js +++ b/web3.js/src/publickey.js @@ -12,7 +12,7 @@ export class PublicKey { /** * Create a new PublicKey object */ - constructor(number: string | Buffer | Array) { + constructor(number: number | string | Buffer | Array) { for (;;) { if (typeof number === 'string') { diff --git a/web3.js/src/token-program.js b/web3.js/src/token-program.js new file mode 100644 index 0000000000..7e4427d583 --- /dev/null +++ b/web3.js/src/token-program.js @@ -0,0 +1,476 @@ +/** + * @flow + */ + +import assert from 'assert'; +import BN from 'bn.js'; +import * as BufferLayout from 'buffer-layout'; + +import * as Layout from './layout'; +import { + Account, + PublicKey, + SystemProgram, + Transaction, +} from '.'; +import {sendAndConfirmTransaction} from './util/send-and-confirm-transaction'; +import type {Connection} from '.'; + + +/** + * @private + */ +const RustStringLayout = (property: string = 'rustString') => { + const lengthLayout = BufferLayout.u8('length'); + const rsl = BufferLayout.struct( + [ + lengthLayout, + BufferLayout.seq(BufferLayout.u8(), 7, 'u8 into u64 padding'), // TODO: avoid padding + BufferLayout.blob( + BufferLayout.offset(lengthLayout, -8), + 'chars', + ), + ], + property, + ); + const _decode = rsl.decode.bind(rsl); + const _encode = rsl.encode.bind(rsl); + + rsl.decode = (buffer, offset) => { + const data = _decode(buffer, offset); + return data.chars.toString('utf8'); + }; + + rsl.encode = (str, buffer, offset) => { + const data = { + chars: Buffer.from(str, 'utf8'), + }; + return _encode(data, buffer, offset); + }; + + return rsl; +}; + +/** + * Some amount of tokens + */ +export class TokenAmount extends BN { + /** + * Convert to Buffer representation + */ + toBuffer(): Buffer { + const a = super.toArray().reverse(); + const b = Buffer.from(a); + if (b.length === 8) { + return b; + } + assert(b.length < 8, 'TokenAmount too large'); + + const zeroPad = Buffer.alloc(8); + b.copy(zeroPad); + return zeroPad; + } + + /** + * Construct a TokenAmount from Buffer representation + */ + static fromBuffer(buffer: Buffer): TokenAmount { + assert(buffer.length === 8, `Invalid buffer length: ${buffer.length}`); + return new BN( + [...buffer].reverse().map(i => `00${i.toString(16)}`.slice(-2)).join(''), + 16 + ); + } +} + + +/** + * Information about a token + */ +type TokenInfo = { + /** + * Total supply of tokens + */ + supply: TokenAmount, + + /** + * Number of base 10 digits to the right of the decimal place + */ + decimals: number, + + /** + * Descriptive name of this token + */ + name: string, + + /** + * Symbol for this token + */ + symbol: string, +}; + +/** + * @private + */ +const TokenInfoLayout = BufferLayout.struct([ + Layout.uint64('supply'), + BufferLayout.u8('decimals'), + new RustStringLayout('name'), + new RustStringLayout('symbol'), +]); + +/** + * Information about a token account + */ +type TokenAccountInfo = { + /** + * The kind of token this account holds + */ + token: PublicKey, + + /** + * Owner of this account + */ + owner: PublicKey, + + /** + * Amount of tokens this account holds + */ + amount: TokenAmount, + + /** + * The source account for the tokens. + * + * If `source` is null, the source is this account. + * If `source` is not null, the `amount` of tokens in this account represent + * an allowance of tokens that may be transferred from the source account + */ + source: null | PublicKey, +}; + +/** + * @private + */ +const TokenAccountInfoLayout = BufferLayout.struct([ + Layout.publicKey('token'), + Layout.publicKey('owner'), + Layout.uint64('amount'), + BufferLayout.u8('sourceOption'), + Layout.publicKey('source'), +]); + + +type TokenAndPublicKey = [Token, PublicKey]; // This type exists to workaround an esdoc parse error + +/** + * An ERC20-like Token + */ +export class Token { + + /** + * @private + */ + connection: Connection; + + /** + * The public key identifying this token + */ + token: PublicKey; + + /** + * Create a Token object attached to the specific token + * + * @param connection The connection to use + * @param token Public key of the token + */ + constructor(connection: Connection, token: PublicKey) { + Object.assign(this, {connection, token}); + } + + /** + * Create a new Token + * + * @param connection The connection to use + * @param owner User account that will own the returned Token Account + * @param supply Total supply of the new token + * @param name Descriptive name of this token + * @param symbol Symbol for this token + * @param decimals Location of the decimal place + * @return Token object for the newly minted token, Public key of the Token Account holding the total supply of new tokens + */ + static async createNewToken( + connection: Connection, + owner: Account, + supply: TokenAmount, + name: string, + symbol: string, + decimals: number, + ): Promise { + const tokenAccount = new Account(); + const token = new Token(connection, tokenAccount.publicKey); + const initialAccountPublicKey = (await token._newAccount(owner, null)).publicKey; + + let transaction; + + const userdataLayout = BufferLayout.struct([ + BufferLayout.u32('instruction'), + Layout.uint64('supply'), + BufferLayout.u8('decimals'), + new RustStringLayout('name'), + new RustStringLayout('symbol'), + ]); + + let userdata = Buffer.alloc(1024); + { + const encodeLength = userdataLayout.encode( + { + instruction: 0, // NewToken instruction + supply: supply.toBuffer(), + decimals, + name, + symbol, + }, + userdata, + ); + userdata = userdata.slice(0, encodeLength); + } + + // Allocate memory for the tokenAccount account + transaction = SystemProgram.createAccount( + owner.publicKey, + tokenAccount.publicKey, + 1, + 1 + userdata.length, + Token.programId, + ); + await sendAndConfirmTransaction(connection, owner, transaction); + + transaction = new Transaction({ + fee: 0, + keys: [tokenAccount.publicKey, initialAccountPublicKey], + programId: Token.programId, + userdata, + }); + await sendAndConfirmTransaction(connection, tokenAccount, transaction); + + return [token, initialAccountPublicKey]; + } + + /** + * @private + */ + async _newAccount(owner: Account, source: null | PublicKey): Promise { + const tokenAccount = new Account(); + let transaction; + + const userdataLayout = BufferLayout.struct([ + BufferLayout.u32('instruction'), + ]); + + const userdata = Buffer.alloc(userdataLayout.span); + userdataLayout.encode( + { + instruction: 1, // NewTokenAccount instruction + }, + userdata, + ); + + // Allocate memory for the token + transaction = SystemProgram.createAccount( + owner.publicKey, + tokenAccount.publicKey, + 1, + 1 + TokenAccountInfoLayout.span, + Token.programId, + ); + await sendAndConfirmTransaction(this.connection, owner, transaction); + + // Initialize the token account + const keys = [tokenAccount.publicKey, owner.publicKey, this.token]; + if (source) { + keys.push(source); + } + transaction = new Transaction({ + fee: 0, + keys, + programId: Token.programId, + userdata, + }); + await sendAndConfirmTransaction(this.connection, tokenAccount, transaction); + + return tokenAccount; + } + + /** + * Create a new and empty token account. + * + * This account may then be used as a `transfer()` or `approve()` destination + * + * @param owner User account that will own the new token account + * @param source If not null, create a delegate account that when authorized + * may transfer tokens from this `source` account + * @return Public key of the new empty token account + */ + async newAccount(owner: Account, source: null | PublicKey = null): Promise { + return (await this._newAccount(owner, source)).publicKey; + } + + /** + * Retrieve token information + */ + async tokenInfo(): Promise { + const accountInfo = await this.connection.getAccountInfo(this.token); + if (!accountInfo.programId.equals(Token.programId)) { + throw new Error(`Invalid token programId: ${JSON.stringify(accountInfo.programId)}`); + } + + const userdata = Buffer.from(accountInfo.userdata); + + if (userdata.readUInt8(0) !== 1) { + throw new Error(`Invalid token userdata`); + } + const tokenInfo = TokenInfoLayout.decode(userdata, 1); + tokenInfo.supply = TokenAmount.fromBuffer(tokenInfo.supply); + return tokenInfo; + } + + + /** + * Retrieve account information + * + * @param account Public key of the token account + */ + async accountInfo(account: PublicKey): Promise { + const accountInfo = await this.connection.getAccountInfo(account); + if (!accountInfo.programId.equals(Token.programId)) { + throw new Error(`Invalid token account programId`); + } + + const userdata = Buffer.from(accountInfo.userdata); + if (userdata.readUInt8(0) !== 2) { + throw new Error(`Invalid token account userdata`); + } + const tokenAccountInfo = TokenAccountInfoLayout.decode(userdata, 1); + + tokenAccountInfo.token = new PublicKey(tokenAccountInfo.token); + tokenAccountInfo.owner = new PublicKey(tokenAccountInfo.owner); + tokenAccountInfo.amount = TokenAmount.fromBuffer(tokenAccountInfo.amount); + tokenAccountInfo.source = tokenAccountInfo.sourceOption === 0 ? null : new PublicKey(tokenAccountInfo.source); + + if (!tokenAccountInfo.token.equals(this.token)) { + throw new Error( + `Invalid token account token: ${JSON.stringify(tokenAccountInfo.token)} !== ${JSON.stringify(this.token)}` + ); + } + return tokenAccountInfo; + } + + /** + * Transfer tokens to another account + * + * @param owner Owner of the source token account + * @param source Source token account + * @param destination Destination token account + * @param amount Number of tokens to transfer + */ + async transfer( + owner: Account, + source: PublicKey, + destination: PublicKey, + amount: number | TokenAmount, + ): Promise { + + const accountInfo = await this.accountInfo(source); + if (!owner.publicKey.equals(accountInfo.owner)) { + throw new Error('Account owner mismatch'); + } + + const userdataLayout = BufferLayout.struct([ + BufferLayout.u32('instruction'), + Layout.uint64('amount'), + ]); + + const userdata = Buffer.alloc(userdataLayout.span); + userdataLayout.encode( + { + instruction: 2, // Transfer instruction + amount: (new TokenAmount(amount)).toBuffer(), + }, + userdata, + ); + + const keys = [owner.publicKey, source, destination]; + if (accountInfo.source) { + keys.push(accountInfo.source); + } + const transaction = new Transaction({ + fee: 0, + keys, + programId: Token.programId, + userdata, + }); + await sendAndConfirmTransaction(this.connection, owner, transaction); + } + + /** + * Grant a third-party permission to transfer up the specified number of tokens from an account + * + * @param owner Owner of the source token account + * @param source Source token account + * @param delegate Token account authorized to perform a transfer tokens from the source account + * @param amount Maximum number of tokens the delegate may transfer + */ + async approve( + owner: Account, + source: PublicKey, + delegate: PublicKey, + amount: number | TokenAmount + ): Promise { + + const userdataLayout = BufferLayout.struct([ + BufferLayout.u32('instruction'), + Layout.uint64('amount'), + ]); + + const userdata = Buffer.alloc(userdataLayout.span); + userdataLayout.encode( + { + instruction: 3, // Approve instruction + amount: (new TokenAmount(amount)).toBuffer(), + }, + userdata, + ); + + const transaction = new Transaction({ + fee: 0, + keys: [owner.publicKey, source, delegate], + programId: Token.programId, + userdata, + }); + await sendAndConfirmTransaction(this.connection, owner, transaction); + } + + /** + * Remove approval for the transfer of any remaining tokens + * + * @param owner Owner of the source token account + * @param source Source token account + * @param delegate Token account to revoke authorization from + */ + revoke( + owner: Account, + source: PublicKey, + delegate: PublicKey + ): Promise { + return this.approve(owner, source, delegate, 0); + } + + /** + * Program Identifier for the Token program + */ + static get programId(): PublicKey { + return new PublicKey('0x500000000000000000000000000000000000000000000000000000000000000'); + } +} + + diff --git a/web3.js/test/__mocks__/node-fetch.js b/web3.js/test/__mocks__/node-fetch.js index 6e97d03d2d..d26c1454db 100644 --- a/web3.js/test/__mocks__/node-fetch.js +++ b/web3.js/test/__mocks__/node-fetch.js @@ -18,13 +18,15 @@ type RpcResponse = { export const mockRpc: Array<[string, RpcRequest, RpcResponse]> = []; +// 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; + // Suppress lint: 'JestMockFn' is not defined // eslint-disable-next-line no-undef const mock: JestMockFn = jest.fn( (fetchUrl, fetchOptions) => { - // Define DOITLIVE in the environment to test against the real full node - // identified by `url` instead of using the mock - if (process.env.DOITLIVE) { + if (!mockRpcEnabled) { console.log(`Note: node-fetch mock is disabled, testing live against ${fetchUrl}`); return fetch(fetchUrl, fetchOptions); } diff --git a/web3.js/test/connection.test.js b/web3.js/test/connection.test.js index 4cc20f373a..8a3a0ba053 100644 --- a/web3.js/test/connection.test.js +++ b/web3.js/test/connection.test.js @@ -4,10 +4,7 @@ import {Account} from '../src/account'; import {Connection} from '../src/connection'; import {SystemProgram} from '../src/system-program'; import {mockRpc} from './__mocks__/node-fetch'; - -let url = 'http://localhost:8899'; -//url = 'http://testnet.solana.com:8899'; -//url = 'http://master.testnet.solana.com:8899'; +import {url} from './url.js'; const errorMessage = 'Invalid request'; const errorResponse = { diff --git a/web3.js/test/token-program.test.js b/web3.js/test/token-program.test.js new file mode 100644 index 0000000000..8fbaf1d772 --- /dev/null +++ b/web3.js/test/token-program.test.js @@ -0,0 +1,514 @@ +// @flow + +import {Account} from '../src/account'; +import {Connection} from '../src/connection'; +import {Token, TokenAmount} from '../src/token-program'; +import {PublicKey} from '../src/publickey'; +import {mockRpc, mockRpcEnabled} from './__mocks__/node-fetch'; +import {url} from './url.js'; +import type {SignatureStatus} from '../src/connection'; + +if (!mockRpcEnabled) { + // The default of 5 seconds is too slow for live testing sometimes + jest.setTimeout(10000); +} + +function mockGetLastId() { + mockRpc.push([ + url, + { + method: 'getLastId', + params: [], + }, + { + error: null, + result: '2BjEqiiT43J6XskiHdz7aoocjPeWkCPiKD72SiFQsrA2', + } + ]); +} + +function mockGetSignatureStatus(result: SignatureStatus = 'Confirmed') { + mockRpc.push([ + url, + { + method: 'getSignatureStatus', + }, + { + error: null, + result, + }, + ]); +} +function mockSendTransaction() { + mockRpc.push([ + url, + { + method: 'sendTransaction', + }, + { + error: null, + result: '3WE5w4B7v59x6qjyC4FbG2FEKYKQfvsJwqSxNVmtMjT8TQ31hsZieDHcSgqzxiAoTL56n2w5TncjqEKjLhtF4Vk', + } + ]); +} + + +async function newAccountWithTokens(connection: Connection, amount: number = 10): Promise { + const account = new Account(); + + { + mockRpc.push([ + url, + { + method: 'requestAirdrop', + params: [account.publicKey.toBase58(), amount], + }, + { + error: null, + result: '3WE5w4B7v59x6qjyC4FbG2FEKYKQfvsJwqSxNVmtMjT8TQ31hsZieDHcSgqzxiAoTL56n2w5TncjqEKjLhtF4Vk', + } + ]); + } + + await connection.requestAirdrop(account.publicKey, amount); + return account; +} + +// A token created by the first test and used by all subsequent tests +let testToken: Token; + +// Initial owner of the token supply +let initialOwner; +let initialOwnerTokenAccount: PublicKey; + +test('create new token', async () => { + const connection = new Connection(url); + + initialOwner = await newAccountWithTokens(connection); + + { + // mock SystemProgram.createAccount transaction for Token.createNewToken() + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + + // mock Token.newAccount() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus('SignatureNotFound'); + mockGetSignatureStatus(); + + // mock SystemProgram.createAccount transaction for Token.createNewToken() + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + + // mock Token.createNewToken() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus('SignatureNotFound'); + mockGetSignatureStatus(); + } + + [testToken, initialOwnerTokenAccount] = await Token.createNewToken( + connection, + initialOwner, + new TokenAmount(10000), + 'Test token', + 'TEST', + 2 + ); + + { + // mock Token.tokenInfo()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [testToken.token.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 1, + 16, 39, 0, 0, 0, 0, 0, 0, + 2, + 10, 0, 0, 0, 0, 0, 0, 0, 84, 101, 115, 116, 32, 116, 111, 107, 101, 110, + 4, 0, 0, 0, 0, 0, 0, 0, 84, 69, 83, 84 + ], + } + } + ]); + } + + const tokenInfo = await testToken.tokenInfo(); + + expect(tokenInfo.supply.toNumber()).toBe(10000); + expect(tokenInfo.decimals).toBe(2); + expect(tokenInfo.name).toBe('Test token'); + expect(tokenInfo.symbol).toBe('TEST'); + + + { + // mock Token.accountInfo()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [initialOwnerTokenAccount.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 2, + ...testToken.token.toBuffer(), + ...initialOwner.publicKey.toBuffer(), + 16, 39, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ], + } + } + ]); + } + + const accountInfo = await testToken.accountInfo(initialOwnerTokenAccount); + + expect(accountInfo.token.equals(testToken.token)).toBe(true); + expect(accountInfo.owner.equals(initialOwner.publicKey)).toBe(true); + expect(accountInfo.amount.toNumber()).toBe(10000); + expect(accountInfo.source).toBe(null); +}); + + +test('create new token account', async () => { + const connection = new Connection(url); + const destOwner = await newAccountWithTokens(connection); + + { + // mock SystemProgram.createAccount transaction for Token.newAccount() + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + + // mock Token.newAccount() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + } + + const dest = await testToken.newAccount(destOwner); + { + // mock Token.accountInfo()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [dest.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 2, + ...testToken.token.toBuffer(), + ...destOwner.publicKey.toBuffer(), + 0, 0, 0, 0, 0, 0, 0, 0, + 0, + ], + } + } + ]); + } + + const accountInfo = await testToken.accountInfo(dest); + + expect(accountInfo.token.equals(testToken.token)).toBe(true); + expect(accountInfo.owner.equals(destOwner.publicKey)).toBe(true); + expect(accountInfo.amount.toNumber()).toBe(0); + expect(accountInfo.source).toBe(null); +}); + + +test('transfer', async () => { + const connection = new Connection(url); + const destOwner = await newAccountWithTokens(connection); + + { + // mock SystemProgram.createAccount transaction for Token.newAccount() + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + + // mock Token.newAccount() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + } + + const dest = await testToken.newAccount(destOwner); + + { + // mock Token.transfer()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [initialOwnerTokenAccount.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 2, + ...testToken.token.toBuffer(), + ...initialOwner.publicKey.toBuffer(), + 123, 0, 0, 0, 0, 0, 0, 0, + 0, + ], + } + } + ]); + + // mock Token.transfer() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + } + + await testToken.transfer( + initialOwner, + initialOwnerTokenAccount, + dest, + 123 + ); + + { + // mock Token.accountInfo()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [dest.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 2, + ...testToken.token.toBuffer(), + ...dest.toBuffer(), + 123, 0, 0, 0, 0, 0, 0, 0, + 0, + ], + } + } + ]); + } + + const destAccountInfo = await testToken.accountInfo(dest); + expect(destAccountInfo.amount.toNumber()).toBe(123); +}); + + +test('approve/revoke', async () => { + const connection = new Connection(url); + const delegateOwner = await newAccountWithTokens(connection); + + { + // mock SystemProgram.createAccount transaction for Token.newAccount() + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + + // mock Token.newAccount() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + } + const delegate = await testToken.newAccount(delegateOwner, initialOwnerTokenAccount); + + { + // mock Token.approve() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + } + + await testToken.approve( + initialOwner, + initialOwnerTokenAccount, + delegate, + 456 + ); + + { + // mock Token.accountInfo()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [delegate.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 2, + ...testToken.token.toBuffer(), + ...delegate.toBuffer(), + 200, 1, 0, 0, 0, 0, 0, 0, + 1, + ...initialOwnerTokenAccount.toBuffer(), + ], + } + } + ]); + } + + let delegateAccountInfo = await testToken.accountInfo(delegate); + + expect(delegateAccountInfo.amount.toNumber()).toBe(456); + if (delegateAccountInfo.source === null) { + throw new Error('source should not be null'); + } else { + expect(delegateAccountInfo.source.equals(initialOwnerTokenAccount)).toBe(true); + } + + { + // mock Token.revoke() transaction + mockGetLastId(); + mockSendTransaction(); + mockGetSignatureStatus(); + } + + await testToken.revoke( + initialOwner, + initialOwnerTokenAccount, + delegate, + ); + + { + // mock Token.accountInfo()'s getAccountInfo + mockRpc.push([ + url, + { + method: 'getAccountInfo', + params: [delegate.toBase58()], + }, + { + error: null, + result: { + program_id: [...Token.programId.toBuffer()], + tokens: 1, + userdata: [ + 2, + ...testToken.token.toBuffer(), + ...delegate.toBuffer(), + 0, 0, 0, 0, 0, 0, 0, 0, + 1, + ...initialOwnerTokenAccount.toBuffer(), + ], + } + } + ]); + } + + delegateAccountInfo = await testToken.accountInfo(delegate); + + expect(delegateAccountInfo.amount.toNumber()).toBe(0); + if (delegateAccountInfo.source === null) { + throw new Error('source should not be null'); + } else { + expect(delegateAccountInfo.source.equals(initialOwnerTokenAccount)).toBe(true); + } +}); + + +test('invalid approve', async () => { + if (mockRpcEnabled) { + console.log('non-live test skipped'); + return; + } + + const connection = new Connection(url); + const owner = await newAccountWithTokens(connection); + + const account1 = await testToken.newAccount(owner); + const account2 = await testToken.newAccount(owner); + + // account2 is not a delegate account + expect( + testToken.approve( + owner, + account1, + account2, + 123 + ) + ).rejects.toThrow(); +}); + + +test('fail on approve overspend', async () => { + if (mockRpcEnabled) { + console.log('non-live test skipped'); + return; + } + + const connection = new Connection(url); + const owner = await newAccountWithTokens(connection); + + const account1 = await testToken.newAccount(owner); + const account1Delegate = await testToken.newAccount(owner, account1); + const account2 = await testToken.newAccount(owner); + + await testToken.transfer( + initialOwner, + initialOwnerTokenAccount, + account1, + 10, + ); + + await testToken.approve( + owner, + account1, + account1Delegate, + 2 + ); + + await testToken.transfer( + owner, + account1Delegate, + account2, + 1, + ); + + await testToken.transfer( + owner, + account1Delegate, + account2, + 1, + ); + + expect( + testToken.transfer( + owner, + account1Delegate, + account2, + 1, + ) + ).rejects.toThrow(); +}); diff --git a/web3.js/test/url.js b/web3.js/test/url.js new file mode 100644 index 0000000000..823e6e8043 --- /dev/null +++ b/web3.js/test/url.js @@ -0,0 +1,9 @@ +// @flow + +/** + * 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'; +