diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index 0ebdf39de9e..822770ab20f 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -963,28 +963,29 @@ export class SolRpc { * @returns */ async getAccountInfo({ address, maxDepth }) { - try { - const accountInfoResponse = await this.rpc.getAccountInfo(address).send(); - - const lamports = accountInfoResponse.value ? Number(accountInfoResponse.value.lamports) : 0; - let effectiveMaxDepth; - if (maxDepth === -1) { - effectiveMaxDepth = Infinity; - } else if (typeof maxDepth === 'number' && maxDepth >= 1) { - effectiveMaxDepth = maxDepth; - } else { - effectiveMaxDepth = 0; - } - const atas = await this.getTokenAccountsByOwner({ address, skipExistenceCheck: true, maxDepth: effectiveMaxDepth }); - return { lamports, atas }; - } catch (err) { - const errMsg = err.message.toLowerCase(); - if (SolKit.isSolanaError(err) && errMsg.includes('json-rpc') && errMsg.includes('should be less than 128 bytes')) { - // This message can occur when getAccountInfo is called with an SPL address instead of a SOL address - throw new Error(SOL_ERROR_MESSAGES.ATA_ADD_SENT_INSTEAD_OF_SOL_ADD); - } - throw err; + // Only lamports and space are read from this response - dataSlice: { length: 0 } tells the RPC to + // omit the account data payload itself. Without it, base64 (unlike base58) has no size limit, so a + // large account's entire data would be sent over the wire on every call for no reason. + const accountInfoResponse = await this.rpc + .getAccountInfo(address, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }) + .send(); + + const lamports = accountInfoResponse.value ? Number(accountInfoResponse.value.lamports) : 0; + let effectiveMaxDepth; + if (maxDepth === -1) { + effectiveMaxDepth = Infinity; + } else if (typeof maxDepth === 'number' && maxDepth >= 1) { + effectiveMaxDepth = maxDepth; + } else { + effectiveMaxDepth = 0; } + const atas = await this.getTokenAccountsByOwner({ address, skipExistenceCheck: true, maxDepth: effectiveMaxDepth }); + return { + lamports, + atas, + owner: accountInfoResponse.value?.owner, + space: accountInfoResponse.value ? Number(accountInfoResponse.value.space) : undefined + }; } /** @@ -998,7 +999,9 @@ export class SolRpc { async getTokenAccountsByOwner({ address, skipExistenceCheck = false, maxDepth = 0 }) { // Only explicit skipExistenceCheck: true should bypass if (skipExistenceCheck !== true) { - const accountInfoResponse = await this.rpc.getAccountInfo(address).send(); + // This is only an existence check - dataSlice: { length: 0 } keeps the account data payload out + // of the response, same reasoning as the getAccountInfo call above. + const accountInfoResponse = await this.rpc.getAccountInfo(address, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }).send(); if (!accountInfoResponse.value) { throw new Error(SOL_ERROR_MESSAGES.SOL_ACCT_NOT_FOUND); } @@ -1111,4 +1114,4 @@ export class SolRpc { return transactionMessage; } -} \ No newline at end of file +} diff --git a/packages/crypto-rpc/lib/sol/error_messages.js b/packages/crypto-rpc/lib/sol/error_messages.js index 8db4507f6cb..385ed9723d7 100644 --- a/packages/crypto-rpc/lib/sol/error_messages.js +++ b/packages/crypto-rpc/lib/sol/error_messages.js @@ -5,6 +5,5 @@ export const SOL_ERROR_MESSAGES = { NON_BASE58_PARAM: 'SolanaError: Provided parameters includes non-base58 string.', TOKEN_ACCOUNT_NOT_FOUND: 'SolanaError: Account could not be found corresponding to provided address', PROVIDED_TOKEN_ADDRESS_IS_SOL: 'SolanaError: Provided address is a SOL address but should be a token address', - SOL_ACCT_NOT_FOUND: 'Provided address does not correspond to an account on the Solana blockchain', - ATA_ADD_SENT_INSTEAD_OF_SOL_ADD: 'SolanaError: Request object exceeds 127 bytes. This may be caused by the provided address belonging to an Associated Token Account instead of a Solana account.', + SOL_ACCT_NOT_FOUND: 'Provided address does not correspond to an account on the Solana blockchain' }; \ No newline at end of file diff --git a/packages/crypto-rpc/test/getAccountInfo.helper.js b/packages/crypto-rpc/test/getAccountInfo.helper.js new file mode 100644 index 00000000000..b4ea7a845ba --- /dev/null +++ b/packages/crypto-rpc/test/getAccountInfo.helper.js @@ -0,0 +1,73 @@ +import { expect } from 'chai'; +import * as SolKit from '@solana/kit'; +import * as SolSystem from '@solana-program/system'; +import * as SolToken from '@solana-program/token'; + +// The program that owns a plain SOL wallet account. Re-exported from @solana-program/system rather than +// hardcoded so the tests stay tied to the same constant the library builds its instructions from. +export const SYSTEM_PROGRAM_ADDRESS = SolSystem.SYSTEM_PROGRAM_ADDRESS; + +// Token-2022 is a literal because @solana-program/token-2022 is not installed here. +// It is used as documentation in tests - in the general case a token's owner may be this program address, although in this codebase it isn't (10 Sept 26) +export const TOKEN_2022_PROGRAM_ADDRESS = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; + +// Structural assertions for a client.getAccountInfo() result, shared by the real integration tests in +// sol.js and spl.js that hit a local validator or devnet. Keeping this in one place means both suites are +// checking the same result shape, so a change to getAccountInfo's return value only needs to be taught to +// one assertion, not two copy-pasted ones. This is a plain function with no describe/it of its own, so +// importing it has no side effects on either file's own test run. +export const assertAccountInfoShape = result => { + expect(result).to.be.an('object').that.is.not.null; + expect(result).to.have.all.keys('lamports', 'atas', 'owner', 'space'); + expect(result).to.have.property('lamports').that.is.a('number').greaterThanOrEqual(0); + expect(result).to.have.property('atas').that.is.an('array'); + // owner and space are both read off the same getAccountInfo response value, so an account that exists + // onchain has both and one that doesn't has neither - they can never disagree. + expect(result.owner === undefined).to.equal(result.space === undefined); + if (result.owner !== undefined) { + expect(result).to.have.property('owner').that.is.a('string'); + expect(SolKit.isAddress(result.owner), `owner ${result.owner} is not a valid address`).to.be.true; + expect(result).to.have.property('space').that.is.a('number').greaterThanOrEqual(0); + } + expect(() => JSON.stringify(result)).not.to.throw(); + for (const ata of result.atas) { + expect(ata).to.be.an('object'); + expect(ata).to.have.property('mint').that.is.a('string'); + expect(ata).to.have.property('pubkey').that.is.a('string'); + expect(ata).to.have.property('state').that.is.a('string'); + expect(ata).to.have.property('atas').that.is.an('array'); + } +}; + +// Records every call SolRpc/SplRpc makes through `this.rpc.getAccountInfo(...)`, along with the raw +// response the real validator sent back for each one. sinon can't stub this directly - the kit RPC +// client is a Proxy with no own properties, so both sinon.stub and a plain property assignment reject +// it ("Attempted to wrap undefined property" / "trap returned falsish"). Swapping in a Proxy that only +// intercepts the one method under test, and lets everything else through untouched, works around that. +// +// This exists to catch a regression where a dataSlice option gets dropped from one of these calls: +// asserting solRpc.getAccountInfo()/getTokenAccountsByOwner() still resolve correctly wouldn't catch +// that, since removing dataSlice doesn't change what those methods return - the account data payload +// they're now silently paying to fetch is simply unused. +export function recordGetAccountInfoCalls(rpcClient) { + const realRpc = rpcClient.rpc; + const calls = []; + rpcClient.rpc = new Proxy(realRpc, { + get(target, prop, _receiver) { + if (prop !== 'getAccountInfo') { + return Reflect.get(target, prop, target); + } + return (...args) => ({ + send: async (...sendArgs) => { + const response = await target.getAccountInfo(...args).send(...sendArgs); + calls.push({ args, response }); + return response; + } + }); + } + }); + return { + calls, + restore: () => { rpcClient.rpc = realRpc; } + }; +} diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index dded304bb2e..dc9d1687cf0 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -12,6 +12,7 @@ import { pipe } from '@solana/functional'; import { SolRpc } from '../lib/sol/SolRpc.js'; import { SOL_ERROR_MESSAGES } from '../lib/sol/error_messages.js'; import { parseInstructions, instructionKeys } from '../lib/sol/transaction-parser.js'; +import { assertAccountInfoShape, recordGetAccountInfoCalls, SYSTEM_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS } from './getAccountInfo.helper.js'; const require = createRequire(import.meta.url); const privateKey1 = require('../blockchain/solana/test/keypair/id.json'); @@ -718,7 +719,7 @@ describe('SOL Tests', () => { }); describe('Mint tests (requires waiting for transaction finalization in places)', function() { - const REQUIRED_FRESH_ACCOUNT_NUMBER = 16; // This number should be updated to reflect the number of TESTS (not required test accounts) in this block + const REQUIRED_FRESH_ACCOUNT_NUMBER = 22; // This number should be updated to reflect the number of TESTS (not required test accounts) in this block let mintKeypair; let resolvedCreateAccountArray; let resolvedCreateAccountIndex = 0; @@ -864,40 +865,71 @@ describe('SOL Tests', () => { const result = await solRpc.getAccountInfo({ address: testKeypair.address }); // Assertions - expect(result).to.be.an('object'); - expect(result).not.to.be.null; - expect(result).to.have.property('lamports').that.is.a('number').greaterThan(0); - expect(result).to.have.property('atas').that.is.an('array').with.length(1); - for (const ata of result.atas) { - expect(ata).to.be.an('object'); - expect(ata).to.have.property('mint').that.is.a('string'); - expect(ata).to.have.property('pubkey').that.is.a('string').not.equal(testKeypair.address); - expect(ata).to.have.property('state').that.is.a('string'); - } + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.is.greaterThan(0); + expect(result).to.have.property('atas').that.has.length(1); + expect(result).to.have.property('space', 0); + expect(result.atas[0]).to.have.property('pubkey').not.equal(testKeypair.address); }); it('can return an account balance and empty array of associated tokens', async () => { const result = await solRpc.getAccountInfo({ address: testKeypair.address }); - expect(result).to.be.an('object'); - expect(result).not.to.be.null; - expect(result).to.have.property('lamports').that.is.a('number').greaterThan(0); - expect(result).to.have.property('atas').that.is.an('array').with.length(0); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.is.greaterThan(0); + expect(result).to.have.property('atas').that.has.length(0); + expect(result).to.have.property('space', 0); }); it('returns an object with lamports 0 if provided address is not found onchain', async () => { const newKeypair = await SolKit.generateKeyPairSigner(); const result = await solRpc.getAccountInfo({ address: newKeypair.address }); - expect(result).to.be.an('object'); - expect(result).not.to.be.null; + assertAccountInfoShape(result); expect(result).to.have.property('lamports').that.equals(0); - expect(result).to.have.property('atas').that.is.an('array').with.length(0); + expect(result).to.have.property('atas').that.has.length(0); + expect(result).to.have.property('owner', undefined); + expect(result).to.have.property('space', undefined); + }); + it('still discovers ATAs owned by an address that has no SOL account of its own', async () => { + // An "owner" on a token account is just a pubkey reference - it can own ATAs (funded by + // someone else acting as payer) without ever having been initialized as a SOL account itself. + // getAccountInfo must not skip ATA discovery just because its own getAccountInfo call for the + // owner came back null - a real owner with zero lamports can still legitimately hold ATAs. + const unfundedOwner = await SolKit.generateKeyPairSigner(); + const ata = await createAta({ solRpc, owner: unfundedOwner.address, mint: mintKeypair.address, payer: senderKeypair }); + + const result = await solRpc.getAccountInfo({ address: unfundedOwner.address }); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.equals(0); + expect(result).to.have.property('owner', undefined); + expect(result).to.have.property('space', undefined); + expect(result).to.have.property('atas').that.has.length(1); + expect(result.atas[0]).to.have.property('pubkey').that.equals(ata); + expect(result.atas[0]).to.have.property('mint').that.equals(mintKeypair.address); + }); + it('reports the System Program as the owner of a SOL wallet address', async () => { + // `owner` is the program that controls the account, not the person holding the keys. Every + // ordinary SOL wallet is a System Program account, so this is the value callers can key off of + // to tell a wallet address apart from a token account without fetching the account data itself. + const result = await solRpc.getAccountInfo({ address: testKeypair.address }); + assertAccountInfoShape(result); + expect(result).to.have.property('owner').that.equals(SYSTEM_PROGRAM_ADDRESS); + expect(result.owner).to.equal('11111111111111111111111111111111'); // The literal value, spelled out as documentation }); - it('throws error if provided address is ATA address', async () => { + it('reports a token program as the owner of an ATA address', async () => { + // An ATA is owned by whichever token program created it. Only the original SPL Token program is + // in play here (getTokenAccountsByOwner queries no other), but asserting against both valid + // token programs documents that a Token-2022 ATA would be an equally correct owner. const ata = await createAta({ solRpc, owner: testKeypair.address, mint: mintKeypair.address, payer: senderKeypair }); - try { - await solRpc.getAccountInfo({ address: ata }); - assert.fail('Expected getAccountInfo to reject, but it resolved.'); - } catch (err) { - expect(err.message).to.equal(SOL_ERROR_MESSAGES.ATA_ADD_SENT_INSTEAD_OF_SOL_ADD); - } + const result = await solRpc.getAccountInfo({ address: ata }); + assertAccountInfoShape(result); + expect(result).to.have.property('owner').that.is.oneOf([SolToken.TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS]); // Either is a valid ATA owner in the general case + expect(result.owner).to.equal(SolToken.TOKEN_PROGRAM_ADDRESS); + expect(result.owner).to.not.equal(SYSTEM_PROGRAM_ADDRESS); + }); + it('returns rent lamports and account space if provided address is an ATA', async () => { + const ata = await createAta({ solRpc, owner: testKeypair.address, mint: mintKeypair.address, payer: senderKeypair }); + const result = await solRpc.getAccountInfo({ address: ata }); + assertAccountInfoShape(result); + const rent = await solRpc.rpc.getMinimumBalanceForRentExemption(SolToken.getTokenSize()).send(); + expect(result).to.deep.equal({ lamports: Number(rent), atas: [], owner: SolToken.TOKEN_PROGRAM_ADDRESS, space: SolToken.getTokenSize() }); }); it('returns nested ATAs across multiple depths in one run', async function() { // !! NOTE !! This is a large test because it involves some sequencing and testing along the way @@ -1026,6 +1058,66 @@ describe('SOL Tests', () => { // Clean up spy getTokenAccountsByOwnerSpy.restore(); }); + it('requests dataSlice: { offset: 0, length: 0 } so the account data payload is omitted while lamports and space are preserved', async () => { + const targetAddress = mintKeypair.address; + + // Control: confirm the mint account actually has non-empty data when NOT sliced (unlike a + // plain SOL wallet account, which has 0 bytes of data regardless of slicing). Without this, + // an empty payload below wouldn't prove the slice is doing anything. Done before the recorder + // is installed so it isn't itself captured as one of the calls under test. + const unsliced = await solRpc.rpc.getAccountInfo(targetAddress, { encoding: 'base64' }).send(); + expect(unsliced.value.data[0]).to.be.a('string').with.length.greaterThan(0); + + const recorder = recordGetAccountInfoCalls(solRpc); + try { + const result = await solRpc.getAccountInfo({ address: targetAddress }); + + expect(recorder.calls).to.have.length(1); + const [{ args, response }] = recorder.calls; + // Regression assertion - the code must not remove dataSlice + expect(args[1]).to.deep.equal({ encoding: 'base64', dataSlice: { offset: 0, length: 0 } }); + expect(response.value.data).to.deep.equal(['', 'base64']); + expect(Number(response.value.lamports)).to.equal(result.lamports); + expect(Number(response.value.space)).to.equal(result.space); + expect(result.space).to.equal(SolToken.getMintSize()); + } finally { + recorder.restore(); + } + }); + }); + + describe('getTokenAccountsByOwner', function() { + it('runs its own existence check against an ATA without hitting the base58 size-limit RPC error', async () => { + // An ATA's account data (165 bytes) is over the RPC's base58 encoding limit (128 bytes), so this + // call only succeeds if the existence check inside getTokenAccountsByOwner requests base64. + const ata = await createAta({ solRpc, owner: testKeypair.address, mint: mintKeypair.address, payer: senderKeypair }); + const result = await solRpc.getTokenAccountsByOwner({ address: ata }); + expect(result).to.be.an('array'); + }); + it('requests dataSlice: { offset: 0, length: 0 } in its existence check so the account data payload is omitted', async () => { + const ata = await createAta({ solRpc, owner: testKeypair.address, mint: mintKeypair.address, payer: senderKeypair }); + + // Control: confirm the ATA actually has non-empty data when NOT sliced, before the recorder is + // installed so this call isn't itself captured as one of the calls under test. + const unsliced = await solRpc.rpc.getAccountInfo(ata, { encoding: 'base64' }).send(); + expect(unsliced.value.data[0]).to.be.a('string').with.length.greaterThan(0); + + const recorder = recordGetAccountInfoCalls(solRpc); + try { + const result = await solRpc.getTokenAccountsByOwner({ address: ata }); + expect(result).to.be.an('array'); + + expect(recorder.calls).to.have.length(1); + const [{ args, response }] = recorder.calls; + // Regression assertion - the code must not remove dataSlice + expect(args[1]).to.deep.equal({ encoding: 'base64', dataSlice: { offset: 0, length: 0 } }); + expect(response.value.data).to.deep.equal(['', 'base64']); + expect(Number(response.value.lamports)).to.be.greaterThan(0); + expect(Number(response.value.space)).to.equal(SolToken.getTokenSize()); + } finally { + recorder.restore(); + } + }); }); }); @@ -1123,15 +1215,10 @@ describe('SOL Tests', () => { }); it('can retrieve account info including lamports and ata array', async () => { const result = await solRpc.getAccountInfo({ address: senderKeypair.address }); - expect(result).to.be.an('object'); - expect(result).not.to.be.null; - expect(result).to.have.property('lamports').that.is.a('number').greaterThan(0); - expect(result).to.have.property('atas').that.is.an('array'); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.is.greaterThan(0); for (const ata of result.atas) { - expect(ata).to.be.an('object'); - expect(ata).to.have.property('mint').that.is.a('string'); - expect(ata).to.have.property('pubkey').that.is.a('string').not.equal(senderKeypair.address); - expect(ata).to.have.property('state').that.is.a('string'); + expect(ata).to.have.property('pubkey').not.equal(senderKeypair.address); } }); describe('getTokenAccountsByOwner', function() { diff --git a/packages/crypto-rpc/test/spl.js b/packages/crypto-rpc/test/spl.js index 6fe3fb0b35e..5b3167ce284 100644 --- a/packages/crypto-rpc/test/spl.js +++ b/packages/crypto-rpc/test/spl.js @@ -8,6 +8,7 @@ import { pipe } from '@solana/functional'; import { SolRpc } from '../lib/sol/SolRpc.js'; import { SplRpc } from '../lib/sol/SplRpc.js'; import { SOL_ERROR_MESSAGES } from '../lib/sol/error_messages.js'; +import { assertAccountInfoShape, SYSTEM_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS } from './getAccountInfo.helper.js'; const require = createRequire(import.meta.url); const privateKey1 = require('../blockchain/solana/test/keypair/id.json'); @@ -243,6 +244,70 @@ describe('SPL Tests', () => { await mintTokens({ splRpc, payer: senderKeypair, mint: mintKeypair.address, mintAuthority: senderKeypair, targetAta: senderAta, decimals: topLevelConfig.decimals }); }); + describe('getAccountInfo', function () { + it('inherits SOL account info including owned ATAs and space', async () => { + const result = await splRpc.getAccountInfo({ address: senderKeypair.address }); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.is.greaterThan(0); + expect(result).to.have.property('space', 0); + expect(result.atas.some(ata => ata.pubkey === senderAta && ata.mint === mintKeypair.address)).to.be.true; + }); + + it('returns ATA rent lamports and space even when it holds tokens', async () => { + const result = await splRpc.getAccountInfo({ address: senderAta }); + assertAccountInfoShape(result); + const rent = await splRpc.rpc.getMinimumBalanceForRentExemption(SolToken.getTokenSize()).send(); + expect(result).to.deep.equal({ lamports: Number(rent), atas: [], owner: SolToken.TOKEN_PROGRAM_ADDRESS, space: SolToken.getTokenSize() }); + }); + + it('reports the System Program as the owner of a SOL wallet address', async () => { + // `owner` is the program that controls the account, not the person holding the keys. Every + // ordinary SOL wallet is a System Program account, so this is the value callers can key off of to + // tell a wallet address apart from a token account without fetching the account data itself. + const result = await splRpc.getAccountInfo({ address: senderKeypair.address }); + assertAccountInfoShape(result); + expect(result).to.have.property('owner').that.equals(SYSTEM_PROGRAM_ADDRESS); + expect(result.owner).to.equal('11111111111111111111111111111111'); // The literal value, spelled out as documentation + }); + + it('reports a token program as the owner of an ATA address', async () => { + // An ATA is owned by whichever token program created it. Only the original SPL Token program is + // in play here (getTokenAccountsByOwner queries no other), but asserting against both valid token + // programs documents that a Token-2022 ATA would be an equally correct owner. + const result = await splRpc.getAccountInfo({ address: senderAta }); + assertAccountInfoShape(result); + expect(result).to.have.property('owner').that.is.oneOf([SolToken.TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS]); // Either is a valid ATA owner in the general case + expect(result.owner).to.equal(SolToken.TOKEN_PROGRAM_ADDRESS); + expect(result.owner).to.not.equal(SYSTEM_PROGRAM_ADDRESS); + }); + + it('still discovers ATAs owned by an address that has no SOL account of its own', async () => { + // An "owner" on a token account is just a pubkey reference - it can own ATAs (funded by someone + // else acting as payer) without ever having been initialized as a SOL account itself. getAccountInfo + // must not skip ATA discovery just because its own getAccountInfo call for the owner came back null. + const unfundedOwner = await SolKit.generateKeyPairSigner(); + const ata = await createAta({ splRpc, owner: unfundedOwner.address, mint: mintKeypair.address, payer: senderKeypair }); + + const result = await splRpc.getAccountInfo({ address: unfundedOwner.address }); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.equals(0); + expect(result).to.have.property('owner', undefined); + expect(result).to.have.property('space', undefined); + expect(result).to.have.property('atas').that.has.length(1); + expect(result.atas[0]).to.have.property('pubkey').that.equals(ata); + expect(result.atas[0]).to.have.property('mint').that.equals(mintKeypair.address); + }); + }); + + describe('getTokenAccountsByOwner', function () { + it('runs its own existence check against an ATA without hitting the base58 size-limit RPC error', async () => { + // An ATA's account data (165 bytes) is over the RPC's base58 encoding limit (128 bytes), so this + // call only succeeds if the existence check inside getTokenAccountsByOwner requests base64. + const result = await splRpc.getTokenAccountsByOwner({ address: senderAta }); + expect(result).to.be.an('array'); + }); + }); + describe('getBalance', function () { it('returns an object representing the token balance', async () => { const value = await splRpc.getBalance({ address: senderAta }); @@ -570,6 +635,13 @@ describe('SPL Tests', () => { expect(result).to.have.property('sourceAta').that.equals(sourceAta); expect(splRpc.getOrCreateAta.callCount).to.equal(0); // b/c destinationAta not included AND sourceAta not included }); + + it('can retrieve account info including lamports and ata array', async () => { + const result = await splRpc.getAccountInfo({ address: senderKeypair.address }); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.is.greaterThan(0); + expect(result.atas.some(ata => ata.pubkey === senderAta)).to.be.true; + }); }); });