Repository navigation
# CLI refuses a delegated metadata authority on canonical accounts (set-authority is effectively unusable) #117
Description
Summary
The on-chain program allows an additional authority — set via set-authority and stored in the metadata account header — to manage a canonical metadata account. The JS CLI never reads that field. It decides isCanonical purely by comparing the signer against the program's upgrade authority, so any delegated authority is rejected client-side and never gets a transaction on chain.
This makes the set-authority feature unusable through the CLI: you can delegate, but the delegate can then do nothing.
You must be the program authority or an authorized authority to manage canonical
metadata accounts. Use the `--non-canonical` option to manage metadata accounts
as a third party.
The error text already promises or an authorized authority, but that path is not implemented.
Environment
| Package | @solana-program/program-metadata |
| Affected versions | 0.5.1 (pinned by anchor-cli 1.0.2) through 0.10.0 (latest at time of writing) — logic is byte-identical across them |
| Cluster | mainnet-beta |
| Reached via | both program-metadata write … directly and anchor idl upgrade (Anchor shells out to this CLI) |
Steps to reproduce
- As the program upgrade authority, create a canonical metadata account:
program-metadata write idl <PROGRAM_ID> ./idl.json --keypair upgrade-authority.json
- Delegate the metadata account to a separate key:
Confirm the header now stores
program-metadata set-authority idl <PROGRAM_ID> --new-authority <DELEGATE_PUBKEY> \ --keypair upgrade-authority.json
<DELEGATE_PUBKEY>in itsauthorityfield. - As the delegate, try to update the IDL:
program-metadata write idl <PROGRAM_ID> ./idl.json --keypair delegate.json
Expected: the write succeeds — the on-chain program accepts the stored authority.
Actual: the CLI exits before building any transaction with the error above.
Root cause
clients/js/src/internals.ts → getPdaDetails()
(bundled at dist/src/cli.js:816 in 0.5.1, dist/src/cli.js:2601 in 0.10.0)
async function getPdaDetails(input) {
const authorityAddress = typeof input.authority === "string" ? input.authority : input.authority.address;
const { authority, programData } = await getProgramAuthority(input.rpc, input.program);
const isCanonical = !!authority && authority === authorityAddress; // ← only the upgrade authority
const [metadata] = await findMetadataPda({
program: input.program,
authority: isCanonical ? null : authorityAddress,
seed: input.seed
});
return { metadata, isCanonical, programData };
}assertValidIsCanonical() (dist/src/cli.js:1244 / :2679) then hard-exits:
function assertValidIsCanonical(isCanonical, options) {
const wantsCanonical = !options.nonCanonical;
if (wantsCanonical && !isCanonical) {
logErrorAndExit("You must be the program authority or an authorized authority to manage canonical metadata accounts. …");
}
}The on-chain check in program/src/processor/mod.rs → validate_authority() is strictly more permissive:
let explicitly_authorized = match account.get_authority() {
Some(metadata_authority) => metadata_authority == authority.address(),
...
} || (account.is_canonical(program.address())
&& is_program_authority(program, program_data, authority.address())?);So the stored-authority branch exists on chain but has no client-side counterpart.
Commands affected
Via getPdaDetailsForWriting() → hard error:
writecreateupdatecloseset-immutable
A related, quieter problem
set-authority and remove-authority call getPdaDetails() directly, without assertValidIsCanonical()
(dist/src/cli.js:1630 / :1659 in 0.5.1, :2900 / :2929 in 0.10.0).
For a delegated signer, isCanonical is false, so findMetadataPda() returns the non-canonical PDA [program, authority, seed]. The CLI then builds a setAuthority instruction against an account that does not exist, and the failure surfaces as a confusing on-chain error rather than a clear message. In practice this means a delegate cannot hand the authority on to another key either.
Important nuance for the fix
This is specifically about updating an existing canonical account. The current behavior is correct for creation: Initialize sets the canonical flag from is_program_authority(...) and stores Address::ZERO as the authority, so only the upgrade authority can ever create a canonical account. A fix should not relax that.
Proposed fix
When the signer is not the upgrade authority, check the canonical account's stored authority before falling back to the non-canonical PDA:
async function getPdaDetails(input: GetPdaDetailsInput): Promise<PdaDetails> {
const authorityAddress =
typeof input.authority === 'string' ? input.authority : input.authority.address;
const { authority: upgradeAuthority, programData } =
await getProgramAuthority(input.rpc, input.program);
// The canonical PDA does not depend on the signer.
const [canonicalPda] = await findMetadataPda({
program: input.program,
authority: null,
seed: input.seed,
});
// Fast path: signer is the program upgrade authority.
if (!!upgradeAuthority && upgradeAuthority === authorityAddress) {
return { metadata: canonicalPda, isCanonical: true, programData };
}
// The on-chain program also accepts the additional authority stored on an
// existing canonical account (see validate_authority in processor/mod.rs).
const account = await fetchMaybeMetadata(input.rpc, canonicalPda);
if (account.exists && unwrapOption(account.data.authority) === authorityAddress) {
return { metadata: canonicalPda, isCanonical: true, programData };
}
const [nonCanonicalPda] = await findMetadataPda({
program: input.program,
authority: authorityAddress,
seed: input.seed,
});
return { metadata: nonCanonicalPda, isCanonical: false, programData };
}Notes:
- The extra
fetchMaybeMetadatacall only happens on the slow path, so the common upgrade-authority flow costs nothing. programDatashould keep being passed to the instruction so the on-chain upgrade-authority branch still works.- Creation semantics are unchanged: if the canonical account does not exist, a non-upgrade-authority signer still falls through to the non-canonical PDA.
set-authority/remove-authoritypick up the fix automatically, since they sharegetPdaDetails().
Documentation note
The README says delegation "can be useful when you don't want to update the metadata using the program authority going forward, or if you use a multisig to create the metadata account". That is precisely the multisig workflow the docs recommend, and it is the workflow this bug blocks: the whole point of delegating is that routine IDL updates should not require a multisig round, but today every update still has to be signed by the multisig-held upgrade authority.
Activity
joncinque commentedon Oct 12, 2026
@lorisleiva can you take a look?
lorisleiva commentedon Oct 12, 2026
Thanks for raising this, I tackled this in #119 and will publish shortly.
Allow delegated authorities to manage canonical metadata accounts in …