From 3d3cdc11d1369ccf8e1800f748f4e49cc91853f1 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Wed, 9 Sep 2026 20:14:34 -0400 Subject: [PATCH 01/11] fixes SolRpc getAccountInfo - now useful for Sol addresses and ATAs, removes error class. Adds `space` to return --- packages/crypto-rpc/lib/sol/SolRpc.js | 33 +++++++++++---------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index 0ebdf39de9e..8b91e56fff5 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -963,28 +963,21 @@ export class SolRpc { * @returns */ async getAccountInfo({ address, maxDepth }) { - try { - const accountInfoResponse = await this.rpc.getAccountInfo(address).send(); + const accountInfoResponse = await this.rpc + .getAccountInfo(address, { encoding: 'base64' }) + .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; + 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, space: accountInfoResponse.value.space }; } /** From f7ffdf06499af1c9cbd5ddbedacf514683b7ca47 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Wed, 9 Sep 2026 20:59:01 -0400 Subject: [PATCH 02/11] handles null in SolRpc getAccountInfo --- packages/crypto-rpc/lib/sol/SolRpc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index 8b91e56fff5..7414c37805a 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -977,7 +977,7 @@ export class SolRpc { effectiveMaxDepth = 0; } const atas = await this.getTokenAccountsByOwner({ address, skipExistenceCheck: true, maxDepth: effectiveMaxDepth }); - return { lamports, atas, space: accountInfoResponse.value.space }; + return { lamports, atas, space: accountInfoResponse.value?.space }; } /** From c3c2157f45eb48717ad0873ce98113b10237d665 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Wed, 9 Sep 2026 21:20:41 -0400 Subject: [PATCH 03/11] update tests to match new getAddressInfo and add extra tests --- packages/crypto-rpc/test/sol.js | 104 +++++++++++++++++++++++++++++--- packages/crypto-rpc/test/spl.js | 18 ++++++ 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index dded304bb2e..3192860685e 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -21,6 +21,96 @@ const privateKey3 = require('../blockchain/solana/test/keypair/id3.json'); const bs58Encoder = SolKit.getBase58Encoder(); +export const testGetAccountInfo = Rpc => { + describe(`${Rpc.name} getAccountInfo unit tests`, () => { + const address = '11111111111111111111111111111111'; + const nativeMint = 'So11111111111111111111111111111111111111112'; + let client; + let accountSend; + let tokenSend; + + beforeEach(() => { + client = new Rpc({ protocol: 'http', host: 'localhost', port: 8899 }); + accountSend = sinon.stub().resolves({ value: { lamports: 1234567890n, space: 0n } }); + tokenSend = sinon.stub().resolves({ value: [] }); + client.rpc = { + getAccountInfo: sinon.stub().returns({ send: accountSend }), + getTokenAccountsByOwner: sinon.stub().returns({ send: tokenSend }) + }; + }); + + it('returns SOL lamports, owned tokens and account space using base64', async () => { + tokenSend.resolves({ value: [{ + pubkey: address, + account: { data: { parsed: { info: { + mint: nativeMint, + state: 'initialized', + tokenAmount: { uiAmount: 2 } + } } } } + }] }); + + expect(await client.getAccountInfo({ address })).to.deep.equal({ + lamports: 1234567890, + space: 0n, + atas: [{ mint: nativeMint, state: 'initialized', pubkey: address, amount: 2, atas: [] }] + }); + sinon.assert.calledOnceWithExactly(client.rpc.getAccountInfo, address, { encoding: 'base64' }); + sinon.assert.calledOnceWithExactly(client.rpc.getTokenAccountsByOwner, + address, { programId: SolToken.TOKEN_PROGRAM_ADDRESS }, { encoding: 'jsonParsed' }); + }); + + it('accepts an ATA and returns its rent balance and 165-byte space', async () => { + const [ata] = await SolToken.findAssociatedTokenPda({ + owner: SolKit.address(address), + mint: SolKit.address(nativeMint), + tokenProgram: SolToken.TOKEN_PROGRAM_ADDRESS + }); + accountSend.resolves({ value: { lamports: 2039280n, space: 165n } }); + + expect(await client.getAccountInfo({ address: ata })).to.deep.equal({ + lamports: 2039280, atas: [], space: 165n + }); + sinon.assert.calledOnceWithExactly(client.rpc.getAccountInfo, ata, { encoding: 'base64' }); + sinon.assert.calledOnceWithExactly(client.rpc.getTokenAccountsByOwner, + ata, { programId: SolToken.TOKEN_PROGRAM_ADDRESS }, { encoding: 'jsonParsed' }); + }); + + it('returns zero lamports and no space for an account that does not exist', async () => { + accountSend.resolves({ value: null }); + expect(await client.getAccountInfo({ address })).to.deep.equal({ + lamports: 0, atas: [], space: undefined + }); + }); + + for (const [maxDepth, expectedDepth] of [[undefined, 0], [0, 0], [1, 1], [6, 6], [-1, Infinity]]) { + it(`preserves ATA discovery with maxDepth=${maxDepth}`, async () => { + const discover = sinon.stub(client, 'getTokenAccountsByOwner').resolves([]); + await client.getAccountInfo({ address, maxDepth }); + sinon.assert.calledOnceWithExactly(discover, { + address, skipExistenceCheck: true, maxDepth: expectedDepth + }); + }); + } + + it('propagates a Solana RPC error without translating it into an ATA-address error', async () => { + const error = new SolKit.SolanaError(SolKit.SOLANA_ERROR__JSON_RPC__INVALID_PARAMS, { + __serverMessage: 'Encoded binary (base 58) data should be less than 128 bytes' + }); + accountSend.rejects(error); + await assert.rejects(client.getAccountInfo({ address }), err => err === error); + sinon.assert.notCalled(client.rpc.getTokenAccountsByOwner); + }); + + it('propagates the original token discovery error', async () => { + const error = new Error('Token discovery unavailable'); + tokenSend.rejects(error); + await assert.rejects(client.getAccountInfo({ address }), err => err === error); + }); + }); +}; + +testGetAccountInfo(SolRpc); + describe('SOL Tests', () => { // Reusable assertion set // IFF isGetTransactionCall, check meta @@ -868,6 +958,7 @@ describe('SOL Tests', () => { 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); + expect(result).to.have.property('space', 0n); for (const ata of result.atas) { expect(ata).to.be.an('object'); expect(ata).to.have.property('mint').that.is.a('string'); @@ -881,6 +972,7 @@ describe('SOL Tests', () => { 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); + expect(result).to.have.property('space', 0n); }); it('returns an object with lamports 0 if provided address is not found onchain', async () => { const newKeypair = await SolKit.generateKeyPairSigner(); @@ -889,15 +981,13 @@ describe('SOL Tests', () => { expect(result).not.to.be.null; 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('space', undefined); }); - it('throws error if provided address is ATA address', async () => { + 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 }); - 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 }); + const rent = await solRpc.rpc.getMinimumBalanceForRentExemption(SolToken.getTokenSize()).send(); + expect(result).to.deep.equal({ lamports: Number(rent), atas: [], space: BigInt(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 diff --git a/packages/crypto-rpc/test/spl.js b/packages/crypto-rpc/test/spl.js index 6fe3fb0b35e..87f1345c49a 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 { testGetAccountInfo } from './sol.js'; const require = createRequire(import.meta.url); const privateKey1 = require('../blockchain/solana/test/keypair/id.json'); @@ -17,6 +18,8 @@ const privateKey2 = require('../blockchain/solana/test/keypair/id2.json'); const bs58Encoder = SolKit.getBase58Encoder(); const tokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +testGetAccountInfo(SplRpc); + describe('SPL Tests', () => { const topLevelConfig = { decimals: 6, // As for USDC/USDT @@ -243,6 +246,21 @@ 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 }); + expect(result).to.have.property('lamports').that.is.a('number').greaterThan(0); + expect(result).to.have.property('space', 0n); + 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 }); + const rent = await splRpc.rpc.getMinimumBalanceForRentExemption(SolToken.getTokenSize()).send(); + expect(result).to.deep.equal({ lamports: Number(rent), atas: [], space: BigInt(SolToken.getTokenSize()) }); + }); + }); + describe('getBalance', function () { it('returns an object representing the token balance', async () => { const value = await splRpc.getBalance({ address: senderAta }); From 7782954a9b7a4c53bc9270bad8d51c1871f4a6ad Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 09:29:17 -0400 Subject: [PATCH 04/11] add `encoding: 'base64'` for getTokenAccountsByOwner --- packages/crypto-rpc/lib/sol/SolRpc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index 7414c37805a..b2c88827caa 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -991,7 +991,7 @@ 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(); + const accountInfoResponse = await this.rpc.getAccountInfo(address, { encoding: 'base64' }).send(); if (!accountInfoResponse.value) { throw new Error(SOL_ERROR_MESSAGES.SOL_ACCT_NOT_FOUND); } From 9825a8343b30f7d6f8c20dad36c676f75e2e8204 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 09:37:56 -0400 Subject: [PATCH 05/11] update sol/spl tests to test actual RPC behavior --- .../crypto-rpc/test/getAccountInfo.helper.js | 23 +++ packages/crypto-rpc/test/sol.js | 139 +++--------------- packages/crypto-rpc/test/spl.js | 24 ++- 3 files changed, 67 insertions(+), 119 deletions(-) create mode 100644 packages/crypto-rpc/test/getAccountInfo.helper.js diff --git a/packages/crypto-rpc/test/getAccountInfo.helper.js b/packages/crypto-rpc/test/getAccountInfo.helper.js new file mode 100644 index 00000000000..3a0e7e5a432 --- /dev/null +++ b/packages/crypto-rpc/test/getAccountInfo.helper.js @@ -0,0 +1,23 @@ +import { expect } from 'chai'; + +// 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', 'space'); + expect(result).to.have.property('lamports').that.is.a('number').greaterThanOrEqual(0); + expect(result).to.have.property('atas').that.is.an('array'); + if (result.space !== undefined) { + expect(result).to.have.property('space').that.is.a('bigint'); + } + 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'); + } +}; diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index 3192860685e..eff816e6860 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 } from './getAccountInfo.helper.js'; const require = createRequire(import.meta.url); const privateKey1 = require('../blockchain/solana/test/keypair/id.json'); @@ -21,96 +22,6 @@ const privateKey3 = require('../blockchain/solana/test/keypair/id3.json'); const bs58Encoder = SolKit.getBase58Encoder(); -export const testGetAccountInfo = Rpc => { - describe(`${Rpc.name} getAccountInfo unit tests`, () => { - const address = '11111111111111111111111111111111'; - const nativeMint = 'So11111111111111111111111111111111111111112'; - let client; - let accountSend; - let tokenSend; - - beforeEach(() => { - client = new Rpc({ protocol: 'http', host: 'localhost', port: 8899 }); - accountSend = sinon.stub().resolves({ value: { lamports: 1234567890n, space: 0n } }); - tokenSend = sinon.stub().resolves({ value: [] }); - client.rpc = { - getAccountInfo: sinon.stub().returns({ send: accountSend }), - getTokenAccountsByOwner: sinon.stub().returns({ send: tokenSend }) - }; - }); - - it('returns SOL lamports, owned tokens and account space using base64', async () => { - tokenSend.resolves({ value: [{ - pubkey: address, - account: { data: { parsed: { info: { - mint: nativeMint, - state: 'initialized', - tokenAmount: { uiAmount: 2 } - } } } } - }] }); - - expect(await client.getAccountInfo({ address })).to.deep.equal({ - lamports: 1234567890, - space: 0n, - atas: [{ mint: nativeMint, state: 'initialized', pubkey: address, amount: 2, atas: [] }] - }); - sinon.assert.calledOnceWithExactly(client.rpc.getAccountInfo, address, { encoding: 'base64' }); - sinon.assert.calledOnceWithExactly(client.rpc.getTokenAccountsByOwner, - address, { programId: SolToken.TOKEN_PROGRAM_ADDRESS }, { encoding: 'jsonParsed' }); - }); - - it('accepts an ATA and returns its rent balance and 165-byte space', async () => { - const [ata] = await SolToken.findAssociatedTokenPda({ - owner: SolKit.address(address), - mint: SolKit.address(nativeMint), - tokenProgram: SolToken.TOKEN_PROGRAM_ADDRESS - }); - accountSend.resolves({ value: { lamports: 2039280n, space: 165n } }); - - expect(await client.getAccountInfo({ address: ata })).to.deep.equal({ - lamports: 2039280, atas: [], space: 165n - }); - sinon.assert.calledOnceWithExactly(client.rpc.getAccountInfo, ata, { encoding: 'base64' }); - sinon.assert.calledOnceWithExactly(client.rpc.getTokenAccountsByOwner, - ata, { programId: SolToken.TOKEN_PROGRAM_ADDRESS }, { encoding: 'jsonParsed' }); - }); - - it('returns zero lamports and no space for an account that does not exist', async () => { - accountSend.resolves({ value: null }); - expect(await client.getAccountInfo({ address })).to.deep.equal({ - lamports: 0, atas: [], space: undefined - }); - }); - - for (const [maxDepth, expectedDepth] of [[undefined, 0], [0, 0], [1, 1], [6, 6], [-1, Infinity]]) { - it(`preserves ATA discovery with maxDepth=${maxDepth}`, async () => { - const discover = sinon.stub(client, 'getTokenAccountsByOwner').resolves([]); - await client.getAccountInfo({ address, maxDepth }); - sinon.assert.calledOnceWithExactly(discover, { - address, skipExistenceCheck: true, maxDepth: expectedDepth - }); - }); - } - - it('propagates a Solana RPC error without translating it into an ATA-address error', async () => { - const error = new SolKit.SolanaError(SolKit.SOLANA_ERROR__JSON_RPC__INVALID_PARAMS, { - __serverMessage: 'Encoded binary (base 58) data should be less than 128 bytes' - }); - accountSend.rejects(error); - await assert.rejects(client.getAccountInfo({ address }), err => err === error); - sinon.assert.notCalled(client.rpc.getTokenAccountsByOwner); - }); - - it('propagates the original token discovery error', async () => { - const error = new Error('Token discovery unavailable'); - tokenSend.rejects(error); - await assert.rejects(client.getAccountInfo({ address }), err => err === error); - }); - }); -}; - -testGetAccountInfo(SolRpc); - describe('SOL Tests', () => { // Reusable assertion set // IFF isGetTransactionCall, check meta @@ -954,38 +865,31 @@ 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); + 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', 0n); - 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'); - } + 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', 0n); }); 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('space', undefined); }); 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: [], space: BigInt(SolToken.getTokenSize()) }); }); @@ -1117,6 +1021,16 @@ describe('SOL Tests', () => { getTokenAccountsByOwnerSpy.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'); + }); + }); }); describe('isBase58', () => { @@ -1213,15 +1127,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 87f1345c49a..1993173a576 100644 --- a/packages/crypto-rpc/test/spl.js +++ b/packages/crypto-rpc/test/spl.js @@ -8,7 +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 { testGetAccountInfo } from './sol.js'; +import { assertAccountInfoShape } from './getAccountInfo.helper.js'; const require = createRequire(import.meta.url); const privateKey1 = require('../blockchain/solana/test/keypair/id.json'); @@ -18,8 +18,6 @@ const privateKey2 = require('../blockchain/solana/test/keypair/id2.json'); const bs58Encoder = SolKit.getBase58Encoder(); const tokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; -testGetAccountInfo(SplRpc); - describe('SPL Tests', () => { const topLevelConfig = { decimals: 6, // As for USDC/USDT @@ -249,18 +247,29 @@ describe('SPL Tests', () => { describe('getAccountInfo', function () { it('inherits SOL account info including owned ATAs and space', async () => { const result = await splRpc.getAccountInfo({ address: senderKeypair.address }); - expect(result).to.have.property('lamports').that.is.a('number').greaterThan(0); + assertAccountInfoShape(result); + expect(result).to.have.property('lamports').that.is.greaterThan(0); expect(result).to.have.property('space', 0n); 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: [], space: BigInt(SolToken.getTokenSize()) }); }); }); + 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 }); @@ -588,6 +597,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; + }); }); }); From f5ab44e4fd84f7bb36f5bddf1108598ec03306b8 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 09:56:47 -0400 Subject: [PATCH 06/11] add tests for ATAs belonging to an uninitialized Solana account (proves possibility and correct functionality) --- packages/crypto-rpc/test/sol.js | 16 ++++++++++++++++ packages/crypto-rpc/test/spl.js | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index eff816e6860..95dacc44a87 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -886,6 +886,22 @@ describe('SOL Tests', () => { expect(result).to.have.property('atas').that.has.length(0); 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('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('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 }); diff --git a/packages/crypto-rpc/test/spl.js b/packages/crypto-rpc/test/spl.js index 1993173a576..9d69bd43766 100644 --- a/packages/crypto-rpc/test/spl.js +++ b/packages/crypto-rpc/test/spl.js @@ -259,6 +259,22 @@ describe('SPL Tests', () => { const rent = await splRpc.rpc.getMinimumBalanceForRentExemption(SolToken.getTokenSize()).send(); expect(result).to.deep.equal({ lamports: Number(rent), atas: [], space: BigInt(SolToken.getTokenSize()) }); }); + + 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('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 () { From 93d6891c248c1682c81e59a507922908a45c9359 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 10:16:44 -0400 Subject: [PATCH 07/11] fix fresh account allocation --- packages/crypto-rpc/test/sol.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index 95dacc44a87..f9eba87207a 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -719,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 = 18; // 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; From 32b6bf90819f5eecd9bc9d08f15393ff8d1af9ce Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 18:21:49 -0400 Subject: [PATCH 08/11] convert SolRPC space output from BigInt to Number --- packages/crypto-rpc/lib/sol/SolRpc.js | 4 ++-- packages/crypto-rpc/lib/sol/error_messages.js | 3 +-- packages/crypto-rpc/test/getAccountInfo.helper.js | 3 ++- packages/crypto-rpc/test/sol.js | 6 +++--- packages/crypto-rpc/test/spl.js | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index b2c88827caa..bc0fe1e1e34 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -977,7 +977,7 @@ export class SolRpc { effectiveMaxDepth = 0; } const atas = await this.getTokenAccountsByOwner({ address, skipExistenceCheck: true, maxDepth: effectiveMaxDepth }); - return { lamports, atas, space: accountInfoResponse.value?.space }; + return { lamports, atas, space: accountInfoResponse.value ? Number(accountInfoResponse.value.space) : undefined }; } /** @@ -1104,4 +1104,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 index 3a0e7e5a432..330f68effd5 100644 --- a/packages/crypto-rpc/test/getAccountInfo.helper.js +++ b/packages/crypto-rpc/test/getAccountInfo.helper.js @@ -11,8 +11,9 @@ export const assertAccountInfoShape = result => { expect(result).to.have.property('lamports').that.is.a('number').greaterThanOrEqual(0); expect(result).to.have.property('atas').that.is.an('array'); if (result.space !== undefined) { - expect(result).to.have.property('space').that.is.a('bigint'); + 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'); diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index f9eba87207a..ff411f42edc 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -868,7 +868,7 @@ describe('SOL Tests', () => { 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', 0n); + 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 () => { @@ -876,7 +876,7 @@ describe('SOL Tests', () => { 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', 0n); + 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(); @@ -907,7 +907,7 @@ describe('SOL Tests', () => { 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: [], space: BigInt(SolToken.getTokenSize()) }); + expect(result).to.deep.equal({ lamports: Number(rent), atas: [], 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 diff --git a/packages/crypto-rpc/test/spl.js b/packages/crypto-rpc/test/spl.js index 9d69bd43766..cb34f59360c 100644 --- a/packages/crypto-rpc/test/spl.js +++ b/packages/crypto-rpc/test/spl.js @@ -249,7 +249,7 @@ describe('SPL Tests', () => { 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', 0n); + expect(result).to.have.property('space', 0); expect(result.atas.some(ata => ata.pubkey === senderAta && ata.mint === mintKeypair.address)).to.be.true; }); @@ -257,7 +257,7 @@ describe('SPL Tests', () => { 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: [], space: BigInt(SolToken.getTokenSize()) }); + expect(result).to.deep.equal({ lamports: Number(rent), atas: [], space: SolToken.getTokenSize() }); }); it('still discovers ATAs owned by an address that has no SOL account of its own', async () => { From cf453acbdf7db84c8b4e3174b20692d40aeeceb5 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 20:46:48 -0400 Subject: [PATCH 09/11] add data offset/limit on rpc getAccountInfo calls --- packages/crypto-rpc/lib/sol/SolRpc.js | 9 +++- .../crypto-rpc/test/getAccountInfo.helper.js | 33 ++++++++++++ packages/crypto-rpc/test/sol.js | 54 ++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index bc0fe1e1e34..ac8158215ab 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -963,8 +963,11 @@ export class SolRpc { * @returns */ async getAccountInfo({ address, maxDepth }) { + // 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' }) + .getAccountInfo(address, { encoding: 'base64', dataSlice: { offset: 0, length: 0 } }) .send(); const lamports = accountInfoResponse.value ? Number(accountInfoResponse.value.lamports) : 0; @@ -991,7 +994,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, { encoding: 'base64' }).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); } diff --git a/packages/crypto-rpc/test/getAccountInfo.helper.js b/packages/crypto-rpc/test/getAccountInfo.helper.js index 330f68effd5..c6f8a3d35ac 100644 --- a/packages/crypto-rpc/test/getAccountInfo.helper.js +++ b/packages/crypto-rpc/test/getAccountInfo.helper.js @@ -22,3 +22,36 @@ export const assertAccountInfoShape = result => { 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 ff411f42edc..502b3b9c6c3 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -12,7 +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 } from './getAccountInfo.helper.js'; +import { assertAccountInfoShape, recordGetAccountInfoCalls } from './getAccountInfo.helper.js'; const require = createRequire(import.meta.url); const privateKey1 = require('../blockchain/solana/test/keypair/id.json'); @@ -719,7 +719,7 @@ describe('SOL Tests', () => { }); describe('Mint tests (requires waiting for transaction finalization in places)', function() { - const REQUIRED_FRESH_ACCOUNT_NUMBER = 18; // This number should be updated to reflect the number of TESTS (not required test accounts) in this block + const REQUIRED_FRESH_ACCOUNT_NUMBER = 20; // 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; @@ -1036,6 +1036,32 @@ 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() { @@ -1046,6 +1072,30 @@ describe('SOL Tests', () => { 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(); + } + }); }); }); From 6d0b66d1d134305f8190343e7ea685122b13435c Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 22:19:34 -0400 Subject: [PATCH 10/11] output account owner (i.e. SYSTEM_PROGRAM_ADDRESS or a defined token address) on SolRPC.getAccountInfo --- packages/crypto-rpc/lib/sol/SolRpc.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/crypto-rpc/lib/sol/SolRpc.js b/packages/crypto-rpc/lib/sol/SolRpc.js index ac8158215ab..822770ab20f 100644 --- a/packages/crypto-rpc/lib/sol/SolRpc.js +++ b/packages/crypto-rpc/lib/sol/SolRpc.js @@ -980,7 +980,12 @@ export class SolRpc { effectiveMaxDepth = 0; } const atas = await this.getTokenAccountsByOwner({ address, skipExistenceCheck: true, maxDepth: effectiveMaxDepth }); - return { lamports, atas, space: accountInfoResponse.value ? Number(accountInfoResponse.value.space) : undefined }; + return { + lamports, + atas, + owner: accountInfoResponse.value?.owner, + space: accountInfoResponse.value ? Number(accountInfoResponse.value.space) : undefined + }; } /** From f1772530cd9654621dd8b3436454e8357efa6373 Mon Sep 17 00:00:00 2001 From: Michael Jay Date: Thu, 10 Sep 2026 22:37:06 -0400 Subject: [PATCH 11/11] add owner tests for SolRPC.getAccountInfo --- .../crypto-rpc/test/getAccountInfo.helper.js | 20 +++++++++++-- packages/crypto-rpc/test/sol.js | 28 +++++++++++++++++-- packages/crypto-rpc/test/spl.js | 26 +++++++++++++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/crypto-rpc/test/getAccountInfo.helper.js b/packages/crypto-rpc/test/getAccountInfo.helper.js index c6f8a3d35ac..b4ea7a845ba 100644 --- a/packages/crypto-rpc/test/getAccountInfo.helper.js +++ b/packages/crypto-rpc/test/getAccountInfo.helper.js @@ -1,4 +1,15 @@ 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 @@ -7,10 +18,15 @@ import { expect } from 'chai'; // 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', 'space'); + 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'); - if (result.space !== undefined) { + // 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(); diff --git a/packages/crypto-rpc/test/sol.js b/packages/crypto-rpc/test/sol.js index 502b3b9c6c3..dc9d1687cf0 100644 --- a/packages/crypto-rpc/test/sol.js +++ b/packages/crypto-rpc/test/sol.js @@ -12,7 +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 } from './getAccountInfo.helper.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'); @@ -719,7 +719,7 @@ describe('SOL Tests', () => { }); describe('Mint tests (requires waiting for transaction finalization in places)', function() { - const REQUIRED_FRESH_ACCOUNT_NUMBER = 20; // 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; @@ -884,6 +884,7 @@ describe('SOL Tests', () => { assertAccountInfoShape(result); expect(result).to.have.property('lamports').that.equals(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 () => { @@ -897,17 +898,38 @@ describe('SOL Tests', () => { 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('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 }); + 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: [], space: SolToken.getTokenSize() }); + 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 diff --git a/packages/crypto-rpc/test/spl.js b/packages/crypto-rpc/test/spl.js index cb34f59360c..5b3167ce284 100644 --- a/packages/crypto-rpc/test/spl.js +++ b/packages/crypto-rpc/test/spl.js @@ -8,7 +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 } from './getAccountInfo.helper.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'); @@ -257,7 +257,28 @@ describe('SPL Tests', () => { 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: [], space: SolToken.getTokenSize() }); + 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 () => { @@ -270,6 +291,7 @@ describe('SPL Tests', () => { 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);