Skip to content

register_agent: signer privilege escalation when owner != payer with additionalMetadata #4

@tenequm

Description

@tenequm

Bug

register_agent fails with Cross-program invocation with unauthorized signer or writable account when both conditions are true:

  1. owner != payer (minting an agent NFT on behalf of a different wallet)
  2. additionalMetadata is provided (non-null, non-empty)

The default SDK path (owner defaults to payer) is unaffected.

Root cause

In step 2g of register_agent.rs (lines 282-296), the update_field CPI uses owner as the metadata update authority:

let update_field_ix = spl_token_metadata_interface::instruction::update_field(
    &anchor_spl::token_2022::ID,
    &ctx.accounts.agent_mint.key(),
    &ctx.accounts.owner.key(),  // update_authority
    ...
);

anchor_lang::solana_program::program::invoke(
    &update_field_ix,
    &[
        ctx.accounts.agent_mint.to_account_info(),
        ctx.accounts.owner.to_account_info(),
    ],
)?;

The SPL update_field instruction marks update_authority as AccountMeta::new_readonly(*update_authority, true) - a required signer. But owner is defined as UncheckedAccount (line 30), not Signer, so its AccountInfo has is_signer = false. The runtime detects signer privilege escalation and rejects the CPI before it dispatches.

Note: the ATA creation (step 2i) is NOT the cause - create_associated_token_account does not require the wallet/owner as a signer.

Reproduction

The simulation logs show:

'Program log: TokenMetadataInstruction: Initialize',   // step 2f succeeds
'Program TokenzQd... success',
"Fo2EYEYbnJTNnBnbAgnjnG1c2fixpFn1vSUUHSeoHhRP's signer privilege escalated",  // step 2g fails immediately
'Program satiRkxEiwZ51cv8PRu8UMzuaqeaNU9jABo6oAFMsLe failed: Cross-program invocation with unauthorized signer or writable account'

No intermediate CPI invocation log between TokenMetadata Initialize and the escalation error - the runtime rejects update_field before dispatching.

Impact

  • Criticality: Low. Only affects owner != payer + additionalMetadata combo. No data corruption, fund loss, or security issue.
  • Default SDK usage is fine - owner defaults to payer.address, so the signer requirement is satisfied implicitly.
  • Blocks use case: a hot wallet (payer) minting an agent NFT for a different owner with metadata fields.

Fix options

Option A: Payer as temporary update authority (recommended)

Use payer as the initial metadata update authority during registration, then transfer authority to owner at the end. No SDK breaking change - owner stays Address.

  1. initialize_metadata (line 252): change &ctx.accounts.owner.key() to &ctx.accounts.payer.key()
  2. update_field calls (line 285): change &ctx.accounts.owner.key() to &ctx.accounts.payer.key()
  3. After all metadata writes, add a CPI to transfer update authority from payer to owner:
let transfer_authority_ix = spl_token_metadata_interface::instruction::update_authority(
    &anchor_spl::token_2022::ID,
    &ctx.accounts.agent_mint.key(),
    &ctx.accounts.payer.key(),  // current authority (signer)
    ctx.accounts.owner.key(),   // new authority
);

anchor_lang::solana_program::program::invoke(
    &transfer_authority_ix,
    &[
        ctx.accounts.agent_mint.to_account_info(),
        ctx.accounts.payer.to_account_info(),
    ],
)?;

Pros: No SDK-level changes, owner never needs to sign, owner ends up as update authority post-registration.
Cons: Verify the update_authority CPI accounts are correct (same runtime-panic risk as SAS CPIs).

Option B: Make owner a Signer

Change owner from UncheckedAccount to Signer in the accounts struct.

Pros: Simplest code change.
Cons: Breaking SDK change (owner becomes TransactionSigner instead of Address). Blocks the hot-wallet-mints-for-user pattern entirely since the owner's private key would be required.

Option C: Skip additionalMetadata during registration

Remove the update_field loop from register_agent. Metadata fields would be set post-registration via updateAgentMetadata (which already requires owner to sign).

Pros: Simplest change, removes the problematic code path entirely.
Cons: Changes the API contract (registration no longer sets metadata atomically). Requires two transactions for the full flow.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions