Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 15 additions & 22 deletions packages/crypto-rpc/lib/sol/SolRpc.js
Original file line number Diff line number Diff line change
Expand Up @@ -963,28 +963,21 @@ export class SolRpc {
* @returns
*/
async getAccountInfo({ address, maxDepth }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bitpay calls getAccountInfo through ChainInterface in isAddressPayable (lib/utils.js:511) and relies on the catch at :549 to return false for ATA addresses. With the throw gone an ATA passes validation as a SOL destination, and SOL sent to an ATA is stuck unless the account gets closed. We need a guard on the bitpay side first, something like reject when space > 0, or keep some way to flag ATAs here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. I'll make that change in the bitpay MR that pulls this crypto-rpc change in - I'll have you review that too so we'll make sure that gets through. That method is actually what precipitated this change in the first place.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ATA_ADD_SENT_INSTEAD_OF_SOL_ADD has no references left after this PR, the throw and the test that used it are both gone. error_messages.js:9 can be deleted, unless we end up keeping the throw per the other comment.

}
throw err;
const accountInfoResponse = await this.rpc
.getAccountInfo(address, { encoding: 'base64' })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing here reads .data, but base64 drops the old base58 size limit that was keeping these responses small. A big account now sends its whole data payload on every call. Adding dataSlice: { offset: 0, length: 0 } keeps the fix and the response stays tiny. Same applies to the call at :994.

.send();
Comment thread
MichaelAJay marked this conversation as resolved.

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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

space comes back as a bigint but everything else in this return is Number coerced, including lamports on the same line. Two real problems: JSON.stringify throws on bigint, and 0n is falsy, so any if (!space) check rejects normal wallets since 0 is the typical wallet space. Suggest space: accountInfoResponse.value ? Number(accountInfoResponse.value.space) : undefined.

}

/**
Expand All @@ -998,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);
}
Expand Down
23 changes: 23 additions & 0 deletions packages/crypto-rpc/test/getAccountInfo.helper.js
Original file line number Diff line number Diff line change
@@ -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');
}
};
81 changes: 48 additions & 33 deletions packages/crypto-rpc/test/sol.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 = 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;
Expand Down Expand Up @@ -864,40 +865,49 @@ 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', 0n);
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('throws error if provided address is ATA address', async () => {
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 });
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);
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
Expand Down Expand Up @@ -1027,6 +1037,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', () => {
Expand Down Expand Up @@ -1123,15 +1143,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() {
Expand Down
50 changes: 50 additions & 0 deletions packages/crypto-rpc/test/spl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from './getAccountInfo.helper.js';

const require = createRequire(import.meta.url);
const privateKey1 = require('../blockchain/solana/test/keypair/id.json');
Expand Down Expand Up @@ -243,6 +244,48 @@ 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', 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()) });
});

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 () {
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 });
Expand Down Expand Up @@ -570,6 +613,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;
});
});
});

Expand Down