Skip to content

Commit cb8166a

Browse files
authored
feat(ts/idl): Update IDL fetching for the new PMP metadata (#4253)
* feat(ts/idl): Update IDL fetching for the new PMP metadata * chore(ts): Update some uses of `Buffer` to TS 5.9 compatibility * tests(idl): Use TS API in tests to fetch IDL * cli(idl): Force install PMP CLI on first use * refactor: Don't use extra dependencies for decoding IDL metadata
1 parent fcf2e67 commit cb8166a

File tree

8 files changed

+173
-48
lines changed

8 files changed

+173
-48
lines changed

cli/src/metadata.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,14 @@ impl IdlCommand {
4141

4242
pub fn status(self) -> io::Result<ExitStatus> {
4343
let mut command = Command::new("npx");
44+
// Force on first-time install
45+
command.arg("--yes");
46+
// Use pinned version
4447
command.arg(format!(
45-
"@solana-program/program-metadata@{PMP_CLIENT_VERSION}"
48+
"--package=@solana-program/program-metadata@{PMP_CLIENT_VERSION}"
4649
));
50+
command.arg("--");
51+
command.arg("program-metadata");
4752
command.args(["--rpc", &self.rpc_url]);
4853
command.args(self.subcommand.args());
4954
command.status()

tests/anchor-cli-idl/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
testLargeIdl.json

tests/anchor-cli-idl/test.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
set -euo pipefail
44

5+
# FIXME: For some reason, in CI, executing with NPX results in `sh: program-metadata: not found`
6+
# Installing this globally fixes this in CI, but this should be investigated and fixed properly
7+
# Implementing IDL fetching via Rust client will make this redundant
8+
npm install --global @solana-program/program-metadata@0.5.1
59
DEPLOYER_KEYPAIR="keypairs/deployer-keypair.json"
610
PROGRAM_ONE="2uA3amp95zsEHUpo8qnLMhcFAUsiKVEcKHXS1JetFjU5"
711

tests/anchor-cli-idl/tests/idl.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,7 @@ describe("Test CLI IDL commands", () => {
1515
const programOne = anchor.workspace.IdlCommandsOne as Program<IdlCommandsOne>;
1616
const programTwo = anchor.workspace.IdlCommandsTwo as Program<IdlCommandsTwo>;
1717

18-
// FIXME: Once the TS client is updated to use PMP, this should use the TS API and not CLI
19-
const fetchIdl = async (programId) => {
20-
let idl;
21-
try {
22-
idl = execSync(`anchor idl fetch ${programId}`).toString();
23-
} catch {
24-
// CLI errors on failure, TS API returns null
25-
return null;
26-
}
27-
return JSON.parse(idl);
28-
};
18+
const fetchIdl = anchor.Program.fetchIdl;
2919

3020
it("Can initialize IDL account", async () => {
3121
execSync(

ts/packages/anchor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
"test": "jest tests --detectOpenHandles"
3434
},
3535
"dependencies": {
36-
"@anchor-lang/errors": "^0.32.1",
3736
"@anchor-lang/borsh": "^0.32.1",
37+
"@anchor-lang/errors": "^0.32.1",
3838
"@noble/hashes": "^1.3.1",
3939
"@solana/web3.js": "^1.69.0",
4040
"bn.js": "^5.1.2",

ts/packages/anchor/src/idl.ts

Lines changed: 153 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { bs58, utf8 } from "./utils/bytes/index.js";
2+
import { inflate, ungzip } from "pako";
13
import camelCase from "camelcase";
24
import { Buffer } from "buffer";
35
import { PublicKey } from "@solana/web3.js";
4-
import * as borsh from "@anchor-lang/borsh";
56

67
export type Idl = {
78
address: string;
@@ -271,36 +272,167 @@ export function isCompositeAccounts(
271272
return "accounts" in accountItem;
272273
}
273274

274-
// Deterministic IDL address as a function of the program id.
275-
export async function idlAddress(programId: PublicKey): Promise<PublicKey> {
276-
const base = (await PublicKey.findProgramAddress([], programId))[0];
277-
return await PublicKey.createWithSeed(base, seed(), programId);
275+
// Account format defined at
276+
// https://github.com/solana-program/program-metadata/blob/734e947d/clients/js/src/generated/accounts/metadata.ts#L123-L138
277+
const PROGRAM_METADATA_PROGRAM_ID = new PublicKey(
278+
"ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S"
279+
);
280+
const IDL_METADATA_SEED = "idl";
281+
const ACCOUNT_DISCRIMINATOR_METADATA = 2;
282+
const DATA_SOURCE_DIRECT = 0;
283+
// Only JSON formatted data is currently supported
284+
export const FORMAT_JSON = 1;
285+
const SEED_SIZE = 16;
286+
const DATA_LENGTH_SIZE = 4;
287+
const DATA_LENGTH_PADDING = 5;
288+
const ZEROABLE_OPTION_PUBKEY_SIZE = 32;
289+
const METADATA_HEADER_SIZE =
290+
1 + 32 + ZEROABLE_OPTION_PUBKEY_SIZE + 1 + 1 + SEED_SIZE + 1 + 1 + 1 + 1;
291+
292+
export enum MetadataCompression {
293+
None = 0,
294+
Gzip = 1,
295+
Zlib = 2,
278296
}
279297

280-
// Seed for generating the idlAddress.
281-
export function seed(): string {
282-
return "anchor:idl";
298+
export enum MetadataEncoding {
299+
None = 0,
300+
Utf8 = 1,
301+
Base58 = 2,
302+
Base64 = 3,
283303
}
284304

285-
// The on-chain account of the IDL.
286-
export interface IdlProgramAccount {
287-
authority: PublicKey;
305+
export type MetadataAccount = {
306+
format: number;
307+
dataSource: number;
308+
compression: MetadataCompression;
309+
encoding: MetadataEncoding;
288310
data: Buffer;
311+
};
312+
313+
function encodeMetadataSeed(seed: string): Buffer {
314+
const encodedSeed = Buffer.from(utf8.encode(seed));
315+
if (encodedSeed.length > SEED_SIZE) {
316+
throw new Error(`Metadata seed '${seed}' exceeds ${SEED_SIZE} bytes`);
317+
}
318+
319+
const paddedSeed = Buffer.alloc(SEED_SIZE);
320+
encodedSeed.copy(paddedSeed);
321+
return paddedSeed;
289322
}
290323

291-
const IDL_ACCOUNT_LAYOUT: borsh.Layout<IdlProgramAccount> = borsh.struct([
292-
borsh.publicKey("authority"),
293-
borsh.vecU8("data"),
294-
]);
324+
export function idlAddress(programId: PublicKey): PublicKey {
325+
// Canonical metadata uses a null authority seed, which is serialized as `[]`.
326+
return PublicKey.findProgramAddressSync(
327+
[
328+
programId.toBuffer(),
329+
Buffer.alloc(0),
330+
encodeMetadataSeed(IDL_METADATA_SEED),
331+
],
332+
PROGRAM_METADATA_PROGRAM_ID
333+
)[0];
334+
}
295335

296-
export function decodeIdlAccount(data: Buffer): IdlProgramAccount {
297-
return IDL_ACCOUNT_LAYOUT.decode(data);
336+
export function seed(): string {
337+
return "idl";
298338
}
299339

300-
export function encodeIdlAccount(acc: IdlProgramAccount): Buffer {
301-
const buffer = Buffer.alloc(1000); // TODO: use a tighter buffer.
302-
const len = IDL_ACCOUNT_LAYOUT.encode(acc, buffer);
303-
return buffer.slice(0, len);
340+
export function decodeIdlAccount<IDL extends Idl = Idl>(data: Buffer): IDL {
341+
const minimumSize =
342+
METADATA_HEADER_SIZE + DATA_LENGTH_SIZE + DATA_LENGTH_PADDING;
343+
if (data.length < minimumSize) {
344+
throw new Error("Metadata account is too small");
345+
}
346+
347+
let offset = 0;
348+
const discriminator = data.readUInt8(offset);
349+
offset += 1;
350+
if (discriminator !== ACCOUNT_DISCRIMINATOR_METADATA) {
351+
throw new Error(
352+
`Invalid metadata account discriminator: ${discriminator.toString()}`
353+
);
354+
}
355+
356+
offset += 32; // program
357+
offset += ZEROABLE_OPTION_PUBKEY_SIZE; // authority
358+
offset += 1; // mutable
359+
offset += 1; // canonical
360+
offset += SEED_SIZE; // seed
361+
362+
const encoding = data.readUInt8(offset) as MetadataEncoding;
363+
offset += 1;
364+
365+
const compression = data.readUInt8(offset) as MetadataCompression;
366+
offset += 1;
367+
368+
const format = data.readUInt8(offset);
369+
if (format !== FORMAT_JSON) {
370+
throw new Error(
371+
`IDL has data format '${format}', only JSON IDLs (${FORMAT_JSON}) are supported`
372+
);
373+
}
374+
offset += 1;
375+
376+
const dataSource = data.readUInt8(offset);
377+
if (dataSource !== DATA_SOURCE_DIRECT) {
378+
throw new Error(
379+
`IDL has source '${dataSource}', only directly embedded data (${DATA_SOURCE_DIRECT}) is supported`
380+
);
381+
}
382+
offset += 1;
383+
384+
const dataLength = data.readUInt32LE(offset);
385+
offset += DATA_LENGTH_SIZE + DATA_LENGTH_PADDING;
386+
387+
if (data.length < offset + dataLength) {
388+
throw new Error("Metadata account data is truncated");
389+
}
390+
391+
const blob = data.subarray(offset, offset + dataLength);
392+
const decoded = decodeMetadataData(
393+
uncompressMetadataData(blob, compression),
394+
encoding
395+
);
396+
return JSON.parse(decoded);
397+
}
398+
399+
export function uncompressMetadataData(
400+
data: Buffer,
401+
compression: MetadataCompression
402+
): Buffer {
403+
switch (compression) {
404+
case MetadataCompression.None:
405+
return data;
406+
case MetadataCompression.Gzip:
407+
return Buffer.from(ungzip(data));
408+
case MetadataCompression.Zlib:
409+
return Buffer.from(inflate(data));
410+
default:
411+
throw new Error(
412+
`Unsupported metadata compression: ${String(compression as number)}`
413+
);
414+
}
415+
}
416+
417+
export function decodeMetadataData(
418+
data: Buffer,
419+
encoding: MetadataEncoding
420+
): string {
421+
switch (encoding) {
422+
// 'None' is actually hex-encoded
423+
case MetadataEncoding.None:
424+
return data.toString("hex");
425+
case MetadataEncoding.Utf8:
426+
return utf8.decode(data);
427+
case MetadataEncoding.Base58:
428+
return bs58.encode(data);
429+
case MetadataEncoding.Base64:
430+
return data.toString("base64");
431+
default:
432+
throw new Error(
433+
`Unsupported metadata encoding: ${String(encoding as number)}`
434+
);
435+
}
304436
}
305437

306438
/**

ts/packages/anchor/src/program/index.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { Buffer } from "buffer";
12
import { Commitment, PublicKey } from "@solana/web3.js";
2-
import { inflate } from "pako";
33
import { BorshCoder, Coder } from "../coder/index.js";
44
import {
55
Idl,
@@ -9,7 +9,6 @@ import {
99
idlAddress,
1010
} from "../idl.js";
1111
import Provider, { getProvider } from "../provider.js";
12-
import { utf8 } from "../utils/bytes/index.js";
1312
import { CustomAccountResolver } from "./accounts-resolver.js";
1413
import { Address, translateAddress } from "./common.js";
1514
import { EventManager } from "./event.js";
@@ -345,21 +344,16 @@ export class Program<IDL extends Idl = Idl> {
345344
* @param provider The network and wallet context.
346345
*/
347346
public static async fetchIdl<IDL extends Idl = Idl>(
348-
address: Address,
347+
programAddress: Address,
349348
provider?: Provider
350349
): Promise<IDL | null> {
351350
provider = provider ?? getProvider();
352-
const programId = translateAddress(address);
353-
354-
const idlAddr = await idlAddress(programId);
351+
const programId = translateAddress(programAddress);
352+
const idlAddr = idlAddress(programId);
355353
const accountInfo = await provider.connection.getAccountInfo(idlAddr);
356-
if (!accountInfo) {
357-
return null;
358-
}
359-
// Chop off account discriminator.
360-
let idlAccount = decodeIdlAccount(accountInfo.data.slice(8));
361-
const inflatedIdl = inflate(idlAccount.data);
362-
return JSON.parse(utf8.decode(inflatedIdl));
354+
if (!accountInfo) return null;
355+
356+
return decodeIdlAccount<IDL>(accountInfo.data);
363357
}
364358

365359
/**

ts/packages/anchor/src/utils/pubkey.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Buffer } from "buffer";
21
import { PublicKey } from "@solana/web3.js";
32
import { sha256 } from "@noble/hashes/sha256";
43

0 commit comments

Comments
 (0)