{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreieszru44rikn6abyaiwlweyjz227wkzc24rnavdnne6o3tluoyhvi",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpr4v6ypnob2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreicc5pxzpgsrbsuf36j2tn3upszmhoggjyr6uq57sszuouslk5aqai"
    },
    "mimeType": "image/webp",
    "size": 257812
  },
  "path": "/lymah/cpis-and-pda-signers-what-i-built-and-what-actually-clicked-m6d",
  "publishedAt": "2026-07-03T17:45:51.000Z",
  "site": "https://dev.to",
  "tags": [
    "100daysofcode",
    "anchor",
    "rust",
    "solana",
    "Cross-Program Invocations — Solana docs",
    "CPI with signer seeds — Anchor book",
    "System Program transfer",
    "Token Interface mint_to",
    "PDA signing — Solana cookbook"
  ],
  "textContent": "I spent four days building programs that move SOL and tokens on Solana.\nDay 71 was a simple SOL transfer through a CPI. Day 74 was a mint\nwhose authority belongs to a PDA with no private key — meaning nobody\ncan ever mint outside the program's rules again.\n\nThis post is what I learned between those two points, written for\nsomeone who understands Web2 backend code but has never written a\nSolana program.\n\n##  The One Sentence Version\n\nA CPI is a program calling another program. A PDA signer is how a\nprogram proves it authorized that call without holding a private key.\n\nEverything else is details.\n\n##  What CPIs Actually Are\n\nIn Web2, your backend calls external APIs. A payment service, a\nmessaging provider, a database driver. You pass credentials, the\nservice does something, you get a result.\n\nOn Solana, programs call other programs the same way — except the\n\"credentials\" are not an API key. They are either a wallet signature\nthat propagated from the outer transaction, or a set of seeds that\nprove the calling program derived a specific address.\n\nThe mechanism is `CpiContext`:\n\n\n\n    let cpi_ctx = CpiContext::new(\n        ctx.accounts.system_program.key(), // which program to call\n        Transfer {                          // the accounts it needs\n            from: ctx.accounts.user.to_account_info(),\n            to: ctx.accounts.vault.to_account_info(),\n        },\n    );\n    transfer(cpi_ctx, amount)?; // the instruction\n\n\nThree things: the program to call, the accounts it needs, the\ninstruction to run. The `?` makes it atomic — if the CPI fails,\nthe whole transaction rolls back.\n\n##  The Two Cases\n\n**Case 1: the wallet signs**\n\nWhen a user signs the outer transaction, that signature propagates\nautomatically into any CPI that uses the wallet as an authority.\nYou do not have to do anything special.\n\n\n\n    // User signed the tx → their wallet can authorize this CPI\n    transfer(CpiContext::new(system_program, Transfer {\n        from: user_wallet,\n        to: recipient,\n    }), amount)?;\n\n\nThis is what I used on Day 71 (SOL transfer) and Day 72 (token mint).\nThe program is a policy layer — it checks conditions, then forwards\nthe call.\n\n**Case 2: the program signs**\n\nWhen the program needs to authorize something the user never signed\nfor — like returning SOL from a vault or minting a reward — it uses\nPDA signer seeds:\n\n\n\n    let signer_seeds: &[&[&[u8]]] = &[&[\n        b\"vault\",\n        user_key.as_ref(),\n        &[bump]\n    ]];\n\n    let cpi_ctx = CpiContext::new(system_program, Transfer {\n        from: vault_pda,\n        to: user_wallet,\n    }).with_signer(signer_seeds); // ← this is the entire mechanism\n\n    transfer(cpi_ctx, amount)?;\n\n\nThe runtime re-derives the PDA from those seeds and your program ID.\nIf the result matches the account you passed in, the CPI is\nauthorized. No private key. No human approval.\n\n##  The Vault: Deposit In, Program Signs Out\n\nDay 73 was the clearest demonstration. Two instructions:\n\n\n\n    deposit(amount)  → user wallet signs, SOL flows INTO the vault PDA\n    withdraw(amount) → program signs via seeds, SOL flows OUT of vault PDA\n\n\nThe vault PDA is derived from `[\"vault\", user_pubkey]`. This means:\n\n  * Every user has their own vault at a unique address\n  * The program can only withdraw to the user whose key is in the seeds\n  * Passing a different user's vault produces a seeds mismatch — rejected\n\n\n\nThe test confirmed it:\n\n\n\n    vault after deposit:  500000000 lamports\n    vault after withdraw: 0 lamports\n    ✔ deposits, then the program signs to withdraw\n\n\nAnd the failure test confirmed the protection:\n\n\n\n    Impostor trying to withdraw from original user vault...\n    ✗ FAILURE 2: AnchorError caused by account: vault.\n    Error Code: ConstraintSeeds. Error Number: 2006.\n    A seeds constraint was violated.\n\n\nThe seeds are the authorization policy. Not an ownership check in\nthe handler — the derivation itself.\n\n##  The PDA Mint Authority: Permanent Program Control\n\nDay 74 was the harder version. I created a Token-2022 mint, then\ntransferred mint authority to a PDA:\n\n\n\n    await setAuthority(\n      connection,\n      payer,\n      mint,\n      payer,\n      AuthorityType.MintTokens,\n      mintAuthorityPda, // ← PDA with no private key\n      [],\n      undefined,\n      TOKEN_2022_PROGRAM_ID\n    );\n\n\nAfter this transaction, the only way to mint tokens is through\nthe program's `mint_tokens` instruction. No wallet can call\n`mintTo` directly because no private key corresponds to the\nPDA address. The program is the only entity that can reconstruct\nthe signer seeds at runtime.\n\n\n\n    Mint authority PDA: 8p6j3X6pBDVf4kzXcgk2VnnN3Jo18tJpjWqraMzguSbC\n    Minted base units:  500000000\n    ✔ PDA signs the mint CPI — no human holds mint authority\n    Confirmed: only the program can mint — PDA has no private key\n\n\n##  What This Pattern Unlocks\n\nEvery meaningful DeFi primitive on Solana uses this pattern:\n\nProtocol type | What the PDA controls\n---|---\nVault / escrow | SOL or token release conditions\nAMM | Token pool balances\nLending protocol | Collateral and liquidation\nReward program | When and how much to mint\nDAO treasury | Fund distribution rules\n\nIn every case, the rules live in the program. The PDA enforces\nthat only the program can act. The seeds determine what the program\ncan act on behalf of.\n\n##  The Three Failures That Made It Real\n\nI wrote a test suite that deliberately triggered three CPI failures:\n\n**Failure 1 — Insufficient funds:**\nTried to withdraw 5 SOL from a vault holding 0.1 SOL.\n`✗ FAILURE 1: Simulation failed.`\nThe System Program rejected the transfer before it hit the chain.\n\n**Failure 2 — Wrong signer (seeds mismatch):**\nAn impostor wallet tried to withdraw from a different user's vault.\n\n\n\n    ✗ FAILURE 2: ConstraintSeeds. Error Number: 2006.\n    A seeds constraint was violated.\n\n\nThe PDA derivation produced a different address. Rejected before\nthe handler ran.\n\n**Failure 3 — Wrong program ID:**\nPassed a fake address instead of the System Program.\n\n\n\n    ✗ FAILURE 3: InvalidProgramId. Error Number: 3008.\n    Program ID was not as expected.\n\n\nAnchor validated the program address before the CPI could execute.\n\nAll three caught cleanly. All three tell you exactly what went wrong.\n\n##  What I Would Tell Someone Starting This Week\n\n**The wallet signature propagates automatically.**\nYou do not need `.with_signer` when the user's wallet is the\nauthority. That is only for PDAs.\n\n**`.with_signer` is the entire PDA signing mechanism.**\nEverything else — the CpiContext, the accounts struct, the\ninstruction call — is the same whether a wallet or a PDA signs.\nThe only difference is that one line.\n\n**Store the bump on the account.**\nCompute it once in the `init` instruction with `ctx.bumps.counter`,\nstore it as a field, reuse it on every subsequent call.\nRe-deriving it each time wastes compute.\n\n**The seeds are the authorization policy.**\nA vault derived from `[\"vault\", user_pubkey]` can only be drained\nto that user. You do not need an explicit ownership check — the\nderivation enforces it. If someone passes the wrong account, the\nseeds mismatch and Anchor rejects the transaction.\n\n**`setAuthority` is a point of no return.**\nOnce you transfer mint authority to a PDA, no human can mint outside\nyour program's rules. Make sure those rules are correct before you\ncall it on mainnet.\n\n##  Resources\n\n  * Cross-Program Invocations — Solana docs\n  * CPI with signer seeds — Anchor book\n  * System Program transfer\n  * Token Interface mint_to\n  * PDA signing — Solana cookbook\n\n\n\n_Part of *_ #100DaysOfSolana*_. Building every day._",
  "title": "CPIs and PDA Signers: What I Built and What Actually Clicked"
}