|
| 1 | +import { bs58, utf8 } from "./utils/bytes/index.js"; |
| 2 | +import { inflate, ungzip } from "pako"; |
1 | 3 | import camelCase from "camelcase"; |
2 | 4 | import { Buffer } from "buffer"; |
3 | 5 | import { PublicKey } from "@solana/web3.js"; |
4 | | -import * as borsh from "@anchor-lang/borsh"; |
5 | 6 |
|
6 | 7 | export type Idl = { |
7 | 8 | address: string; |
@@ -271,36 +272,167 @@ export function isCompositeAccounts( |
271 | 272 | return "accounts" in accountItem; |
272 | 273 | } |
273 | 274 |
|
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, |
278 | 296 | } |
279 | 297 |
|
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, |
283 | 303 | } |
284 | 304 |
|
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; |
288 | 310 | 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; |
289 | 322 | } |
290 | 323 |
|
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 | +} |
295 | 335 |
|
296 | | -export function decodeIdlAccount(data: Buffer): IdlProgramAccount { |
297 | | - return IDL_ACCOUNT_LAYOUT.decode(data); |
| 336 | +export function seed(): string { |
| 337 | + return "idl"; |
298 | 338 | } |
299 | 339 |
|
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 | + } |
304 | 436 | } |
305 | 437 |
|
306 | 438 | /** |
|
0 commit comments