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
111 changes: 109 additions & 2 deletions clients/js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import {
getU64Decoder,
HasAddress,
Instruction,
isOffCurveAddress,
MessagePackerInstructionPlan,
MicroLamports,
parseBase64RpcAccount,
ReadonlyUint8Array,
Rpc,
SOLANA_ERROR__INSTRUCTION_PLANS__MAX_INSTRUCTIONS_PER_TRANSACTION_EXCEEDED,
Expand Down Expand Up @@ -66,6 +68,9 @@ export const REALLOC_LIMIT = 10_240;
export const LOADER_V3_PROGRAM_ADDRESS =
'BPFLoaderUpgradeab1e11111111111111111111111' as Address<'BPFLoaderUpgradeab1e11111111111111111111111'>;

export const LOADER_V2_PROGRAM_ADDRESS =
'BPFLoader2111111111111111111111111111111111' as Address<'BPFLoader2111111111111111111111111111111111'>;

export type MetadataInput = {
payer: TransactionSigner;
authority: TransactionSigner;
Expand Down Expand Up @@ -209,10 +214,10 @@ async function getProgramAuthorityForLoaderV3(rpc: Rpc<GetAccountInfoApi>, progr
const programDataAccountData = programDataDecoder.decode(programDataAccount.data);

// Ensure both accounts are valid.
if (programAccountData.discriminator !== 2) {
if (programAccountData.discriminator !== LOADER_V3_PROGRAM_DISCRIMINATOR) {
throw Error('Invalid program discriminator');
}
if (programDataAccountData.discriminator !== 3) {
if (programDataAccountData.discriminator !== LOADER_V3_PROGRAM_DATA_DISCRIMINATOR) {
throw Error('Invalid program data discriminator');
}
if (programAccountData.programData !== programDataAccount.address) {
Expand All @@ -225,6 +230,108 @@ async function getProgramAuthorityForLoaderV3(rpc: Rpc<GetAccountInfoApi>, progr
};
}

/**
* What kind of account holds a program's upgrade authority:
*
* - `none`: no authority; the program is immutable.
* - `keypair`: an on-curve address; a single private key can redeploy the
* program.
* - `pda`: an off-curve address, such as a multisig vault or another program.
*/
export type UpgradeAuthorityKind = 'none' | 'keypair' | 'pda';

export type ProgramUpgradeAuthority =
| {
authority: null;
authorityKind: 'none';
loader: 'v2';
program: Address;
}
| {
/** The address allowed to redeploy the program, or `null` once made immutable. */
authority: Address | null;
authorityKind: UpgradeAuthorityKind;
/** Slot of the most recent deploy or upgrade. */
lastDeployedSlot: bigint;
loader: 'v3';
program: Address;
programData: Address;
};

/**
* Reports who can replace the code of a program — the same data
* `solana program show` prints under `Authority` and `Last Deployed In Slot`.
*
* - Loader v2 programs are immutable by construction.
* - Loader v3 programs report the upgrade authority and deploy slot stored in
* their program data account.
*
* Unlike {@link getProgramAuthority}, which resolves the authority allowed to
* manage canonical metadata, this reports the upgrade authority only, so
* immutable programs have no authority.
*
* Only the header of the program data account is fetched, so the ELF of a
* loader v3 program is never downloaded.
* Throws if the account is not an executable program owned by loader v2 or v3.
*/
export async function fetchProgramUpgradeAuthority(
rpc: Rpc<GetAccountInfoApi>,
program: Address | HasAddress,
): Promise<ProgramUpgradeAuthority> {
const programAddress = toAddress(program);
const programAccount = await fetchEncodedAccount(rpc, programAddress);
assertAccountExists(programAccount);

if (!programAccount.executable) {
throw Error('Program account must be executable');
}

if (programAccount.programAddress === LOADER_V2_PROGRAM_ADDRESS) {
return { authority: null, authorityKind: 'none', loader: 'v2', program: programAddress };
}

if (programAccount.programAddress !== LOADER_V3_PROGRAM_ADDRESS) {
throw Error('Invalid loader program, expected loader v2 or v3');
}

const [programDecoder, programDataDecoder] = getLoaderV3Decoders();
const programAccountData = programDecoder.decode(programAccount.data);
if (programAccountData.discriminator !== LOADER_V3_PROGRAM_DISCRIMINATOR) {
throw Error('Invalid program discriminator');
}

const programData = programAccountData.programData;
const { value } = await rpc
.getAccountInfo(programData, {
dataSlice: { length: LOADER_V3_PROGRAM_DATA_HEADER_LENGTH, offset: 0 },
encoding: 'base64',
})
.send();
const programDataAccount = parseBase64RpcAccount(programData, value);
assertAccountExists(programDataAccount);

const programDataAccountData = programDataDecoder.decode(programDataAccount.data);
if (programDataAccountData.discriminator !== LOADER_V3_PROGRAM_DATA_DISCRIMINATOR) {
throw Error('Invalid program data discriminator');
}

const authority = unwrapOption(programDataAccountData.authority);
return {
authority,
authorityKind: authority === null ? 'none' : isOffCurveAddress(authority) ? 'pda' : 'keypair',
lastDeployedSlot: programDataAccountData.slot,
loader: 'v3',
program: programAddress,
programData,
};
}

const LOADER_V3_PROGRAM_DISCRIMINATOR = 2;
const LOADER_V3_PROGRAM_DATA_DISCRIMINATOR = 3;

/** Discriminator (4) + slot (8) + optional authority (1 + 32). */
const LOADER_V3_PROGRAM_DATA_HEADER_LENGTH = 45;

function getLoaderV3Decoders() {
return [
getStructDecoder([
Expand Down
12 changes: 8 additions & 4 deletions clients/js/test/_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
getU32Encoder,
getU64Encoder,
getUtf8Encoder,
HasAddress,
KeyPairSigner,
lamports,
Lamports,
none,
sol,
solToLamports,
some,
Expand All @@ -33,10 +35,11 @@ import {
PROGRAM_METADATA_PROGRAM_ADDRESS,
programMetadataProgram,
} from '../src';
import { toAddress } from '../src/internals';

export { REALLOC_LIMIT } from '../src';

const PROGRAM_METADATA_BINARY_PATH = path.resolve(
export const PROGRAM_METADATA_BINARY_PATH = path.resolve(
__dirname,
'..',
'..',
Expand Down Expand Up @@ -102,7 +105,8 @@ const getLoaderV3ProgramDataAccountEncoder = () =>
*/
export const createDeployedProgram = async (
client: TestClient,
authority: KeyPairSigner,
authority: Address | HasAddress | null,
slot: bigint = 0n,
): Promise<[Address, Address]> => {
const program = await generateKeyPairSigner();
const [programData] = await getLoaderV3ProgramDataPda(program.address);
Expand All @@ -113,8 +117,8 @@ export const createDeployedProgram = async (
});
const programDataAccountData = getLoaderV3ProgramDataAccountEncoder().encode({
discriminator: 3,
slot: 0n,
authority: some(authority.address),
slot,
authority: authority ? some(toAddress(authority)) : none(),
});

const programSpace = BigInt(programAccountData.length);
Expand Down
122 changes: 122 additions & 0 deletions clients/js/test/fetchProgramUpgradeAuthority.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { readFileSync } from 'node:fs';

import { generateKeyPairSigner, getProgramDerivedAddress } from '@solana/kit';
import { expect, it } from 'vitest';

import { fetchProgramUpgradeAuthority, LOADER_V2_PROGRAM_ADDRESS } from '../src';
import {
createDeployedProgram,
createNativeProgram,
createTestClient,
generateKeyPairSignerWithSol,
PROGRAM_METADATA_BINARY_PATH,
} from './_setup';

it('reports a keypair upgrade authority and the last deployed slot', async () => {
// Given a loader-v3 program upgradeable by a keypair and deployed at slot 123,456.
const client = await createTestClient();
const authority = await generateKeyPairSigner();
const [program, programData] = await createDeployedProgram(client, authority, 123_456n);

// When we fetch its upgrade authority.
const result = await fetchProgramUpgradeAuthority(client.rpc, program);

// Then we expect the keypair authority and deploy slot to be returned.
expect(result).toStrictEqual({
authority: authority.address,
authorityKind: 'keypair',
lastDeployedSlot: 123_456n,
loader: 'v3',
program,
programData,
});
});

it('reports a PDA upgrade authority', async () => {
// Given a loader-v3 program upgradeable by a PDA, e.g. a multisig vault.
const client = await createTestClient();
const [vault] = await getProgramDerivedAddress({
programAddress: (await generateKeyPairSigner()).address,
seeds: ['vault'],
});
const [program] = await createDeployedProgram(client, vault);

// When we fetch its upgrade authority.
const result = await fetchProgramUpgradeAuthority(client.rpc, program);

// Then we expect the authority to be flagged as a PDA.
expect(result).toMatchObject({ authority: vault, authorityKind: 'pda' });
});

it('reports a loader-v3 program made immutable', async () => {
// Given a loader-v3 program whose upgrade authority was removed.
const client = await createTestClient();
const [program, programData] = await createDeployedProgram(client, null, 42n);

// When we fetch its upgrade authority.
const result = await fetchProgramUpgradeAuthority(client.rpc, program);

// Then we expect no authority to be returned.
expect(result).toStrictEqual({
authority: null,
authorityKind: 'none',
lastDeployedSlot: 42n,
loader: 'v3',
program,
programData,
});
});

it('reports a loader-v2 program as immutable', async () => {
// Given a program deployed with loader v2.
const client = await createTestClient();
const program = (await generateKeyPairSigner()).address;
client.svm.addProgramWithLoader(program, readFileSync(PROGRAM_METADATA_BINARY_PATH), LOADER_V2_PROGRAM_ADDRESS);

// When we fetch its upgrade authority.
const result = await fetchProgramUpgradeAuthority(client.rpc, program);

// Then we expect it to be reported as immutable.
expect(result).toStrictEqual({
authority: null,
authorityKind: 'none',
loader: 'v2',
program,
});
});

it('fails for missing accounts', async () => {
// Given an address with no account.
const client = await createTestClient();
const missing = (await generateKeyPairSigner()).address;

// When we try to fetch its upgrade authority.
const promise = fetchProgramUpgradeAuthority(client.rpc, missing);

// Then we expect an error to be thrown.
await expect(promise).rejects.toThrow();
});

it('fails for non-executable accounts', async () => {
// Given a plain, non-executable wallet account.
const client = await createTestClient();
const wallet = await generateKeyPairSignerWithSol(client);

// When we try to fetch its upgrade authority.
const promise = fetchProgramUpgradeAuthority(client.rpc, wallet);

// Then we expect an error to be thrown.
await expect(promise).rejects.toThrow('Program account must be executable');
});

it('fails for programs not owned by loader v2 or v3', async () => {
// Given a native program.
const client = await createTestClient();
const program = await createNativeProgram(client);

// When we try to fetch its upgrade authority.
const promise = fetchProgramUpgradeAuthority(client.rpc, program);

// Then we expect an error to be thrown.
await expect(promise).rejects.toThrow('Invalid loader program, expected loader v2 or v3');
});
Loading