Created
July 2, 2026 16:29
-
-
Save 0x-r4bbit/ead1077de7b5ab6a5ed67b8b6a69f5bc to your computer and use it in GitHub Desktop.
Design note: mint-authority signing model
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Design note: mint-authority signing model | |
| **Status:** Open for team input — no final decision yet. | |
| **Scope:** How the Token program authorizes `mint` and `set_authority` when the mint authority | |
| can be either the token definition account itself or a separate (possibly rotated) account. | |
| **Audience:** LEZ program authors and reviewers. | |
| --- | |
| ## TL;DR | |
| LP-0013 gives fungible tokens a mint authority stored inline as `authority: Option<AccountId>` on | |
| `TokenDefinition::Fungible` (`Some(id)` = mintable by `id`; `None` = fixed supply). Enforcing that | |
| authority runs into a structural problem: a **single instruction cannot statically express two | |
| different signers** (the definition account for self-authority vs. a distinct account for a rotated | |
| authority), and LEZ forbids working around it by passing the same account twice. We've explored four | |
| shapes. The two live candidates are: | |
| - **C — split instructions** (`mint` + `mint_with_authority`, and the same for `set_authority`). | |
| - **D — unified instruction** with a single, always-distinct authority account as the sole signer. | |
| C is implemented today (for `mint`). D is simpler at the token layer but pushes cost into the AMM. | |
| We want the team's read before committing. | |
| --- | |
| ## Background: how authorization works in LEZ | |
| Two facts drive everything below. | |
| 1. **The `#[account(signer)]` marker does two jobs at once.** In the SPEL guest macro it (a) makes | |
| the framework *require* that account to be authorized (`is_authorized`) before the handler runs, | |
| and (b) is the signal the **SPEL client uses to decide which account to attach a signature for** | |
| when building a transaction. The marker is **static per account parameter** — it cannot be "sign | |
| only in some cases." | |
| 2. **LEZ rejects a transaction whose account list contains the same account id twice.** | |
| From `lee/state_machine/src/validated_state_diff.rs`: | |
| ```rust | |
| ensure!( | |
| message.account_ids.iter().collect::<HashSet<_>>().len() == message.account_ids.len(), | |
| LeeError::InvalidInput("Duplicate account_ids found in message"), | |
| ); | |
| ``` | |
| A third, softer fact: **PDA authorization is not a signature.** A program calling via a chained call | |
| authorizes a PDA through its seeds (`is_authorized = true`), with no signer marker and no client | |
| signature involved. So the "who signs" problem is really about *externally keyed* authorities, not | |
| PDAs. | |
| ## The two authorization paths | |
| A mint (or authority rotation) can be approved by one of two kinds of account: | |
| - **Self / PDA authority** — the authority *is* the definition account. It proves authority by being | |
| authorized in the same transaction (a signer for a user token, or a PDA authorized under its seeds | |
| — e.g. the AMM minting its own LP token via a chained call). The authority account and the | |
| definition account are the **same** id. | |
| - **External authority** — the authority was rotated to a **distinct** account (an owner key, or a | |
| different PDA). That account signs; the definition account is mutated but does not sign. | |
| These need **opposite** `signer` wiring on the definition account, and the self path makes the | |
| authority id equal to the definition id. That is the whole problem. | |
| ## Why the obvious fixes don't work | |
| - **"Just mark the definition account as signer."** Works for self authority; breaks external | |
| authority — the framework then *requires* the definition to sign, but in the rotated case only the | |
| external key signs. (This is the bug that made `token_rotate_authority_then_new_authority_can_mint` | |
| fail once it actually ran.) | |
| - **"Just drop the signer marker and enforce inline."** Makes the tests pass (they sign manually), | |
| and PDA/chained-call minting keeps working. But the SPEL client then has **no signer hint**, so it | |
| attaches no signature and CLI-driven minting is not invokable. | |
| - **"Mark the `authority_accounts: Vec` as signer."** The macro can't express this cleanly: the | |
| generated check is `accounts[fixed_index].is_authorized`, which only inspects the *first* trailing | |
| account and **index-panics when the vec is empty** (the AMM self-mint path). Not viable. | |
| - **"Pass the definition account twice — once as definition, once as authority."** Blocked by the | |
| LEZ no-duplicate-accounts rule above. (Note: the LP-0013 demo scripts did exactly this and would | |
| have failed against a real sequencer — they've since been removed.) | |
| --- | |
| ## Options explored | |
| ### Option A — single instruction, `signer` on the definition account | |
| The original LP-0013 shape. | |
| - **Pros:** Simplest; self-mint (including AMM PDA) works; CLI can sign the definition for self-mint. | |
| - **Cons:** **External-authority minting is unreachable** — a rotated authority can never mint via | |
| the normal flow, because the framework forces the definition (not the new authority) to sign. This | |
| contradicts the rotation feature; the rotation integration test cannot pass. | |
| ### Option B — single instruction, no `signer` marker, inline-only enforcement | |
| - **Pros:** Minimal code; all tests pass (they sign manually); PDA/chained-call minting works. | |
| - **Cons:** **Not invokable from any signer-marker-driven client** (SPEL CLI, wallets). The client | |
| has nothing telling it which account to sign, so real transactions attach no signature and fail | |
| the inline `is_authorized` check. Ships a latent "green tests, broken CLI" trap. | |
| ### Option C — split into two instructions *(implemented today for `mint`)* | |
| ```rust | |
| // self / PDA (AMM): definition is the authority and signs / is PDA-authorized | |
| pub fn mint(ctx, | |
| #[account(mut, signer)] definition_account, | |
| user_holding_account, | |
| amount_to_mint) { .. } | |
| // rotated: a distinct authority signs; definition is mutated but does not sign | |
| pub fn mint_with_authority(ctx, | |
| #[account(mut)] definition_account, | |
| user_holding_account, | |
| #[account(signer)] authority_account, | |
| amount_to_mint) { .. } | |
| ``` | |
| - **Pros:** Each instruction has a correct static signer, so the SPEL client signs the right account | |
| in both cases. No duplicate-account issue. AMM keeps self-authority (no new PDA). Both paths fully | |
| usable; all tests pass. | |
| - **Cons:** **Doubles the privileged-instruction surface.** `set_authority` has the *identical* | |
| duality (a rotated authority must be able to rotate/revoke again), so being consistent means also | |
| adding `set_authority_with_authority` → **four** instructions (`mint`, `mint_with_authority`, | |
| `set_authority`, `set_authority_with_authority`). The self-vs-external distinction leaks into every | |
| client and the IDL. The `Instruction` enum grows a near-duplicate variant per operation. | |
| ### Option D — unified instruction, one always-distinct authority account *(proposed)* | |
| Make the authority account **required, always distinct from the definition account, and the sole | |
| signer** for both operations. | |
| ```rust | |
| pub fn mint(ctx, | |
| #[account(mut)] definition_account, | |
| user_holding_account, | |
| #[account(signer)] authority_account, // always present, always the signer, never == definition | |
| amount_to_mint) { .. } | |
| pub fn set_authority(ctx, | |
| #[account(mut)] definition_account, | |
| #[account(signer)] authority_account, | |
| new_authority) { .. } | |
| ``` | |
| Enforce `authority != definition` at creation. The "self-authority" concept goes away: a program | |
| that wants to mint its own token holds the authority on a **separate** account/PDA. | |
| - **Pros:** | |
| - **One instruction per operation, one signer rule, always.** Smallest token API; the client | |
| always signs exactly the authority account. No conditional signer, no duplicate-account risk. | |
| - The self-vs-external distinction disappears from the API and the IDL entirely. | |
| - Uniform mental model: "whoever controls the authority account can mint / rotate / revoke." | |
| - **Cons:** | |
| - **The AMM must gain a dedicated LP-mint-authority PDA** (distinct from the LP definition PDA), | |
| threaded — authorized via seeds — through **all three** LP mint sites (`new_definition.rs`'s two | |
| mints + `add.rs`). This changes the AMM's PDA set and therefore its ImageID. | |
| - Every mint/rotate must carry the authority account, even in the common case. | |
| - A creation-time invariant (`authority != definition`) is required to avoid minting an | |
| unmintable-by-construction token. | |
| - Fixed-supply tokens (`authority: None`) nominally still require the account parameter even though | |
| such a mint always rejects — a minor wart. | |
| ### Rejected — pass the definition account twice | |
| Set the definition account as its own authority and pass it as both the definition and the authority | |
| account. **Rejected:** violates LEZ's no-duplicate-accounts rule; the transaction never reaches the | |
| guest. | |
| --- | |
| ## Comparison | |
| | | A: single + signer | B: single, no marker | C: split (impl.) | D: unified distinct authority | | |
| |---|---|---|---|---| | |
| | External authority can mint | ❌ | ✅ (inline) | ✅ | ✅ | | |
| | Invokable via SPEL client | self only | ❌ | ✅ | ✅ | | |
| | Instruction count (mint+set) | 2 | 2 | **4** | **2** | | |
| | One uniform signing rule | ❌ | ❌ | ❌ | ✅ | | |
| | AMM change | none | none | none | **new authority PDA ×3 sites** | | |
| | Duplicate-account risk | n/a | n/a | none | none (invariant) | | |
| | Where complexity lives | — | — | token API + every client | AMM only | | |
| ## Open questions for the team | |
| 1. Do we expect **externally keyed** mint authorities to be driven through the SPEL CLI / standard | |
| wallets (which need signer markers), or only through programs/PDAs via chained calls (which don't)? | |
| If only the latter, Option B is technically sufficient and the whole split/unify question is moot. | |
| 2. Is collapsing "self-authority" (Option D) acceptable given it forces the AMM — and any future | |
| self-minting program — to maintain a **separate authority PDA**? Is one extra PDA per self-minting | |
| program a fair price for a 2-instruction, single-signer token API? | |
| 3. Is the growth to **four** privileged instructions (Option C) an acceptable long-term API cost, or | |
| does the near-duplication argue for D? | |
| 4. Should any of this instead be solved **in the framework** — e.g. a first-class way to mark "the | |
| authority account, whichever it is, as the signer," so neither the token API nor every program has | |
| to encode the self-vs-external split by hand? (This mirrors the SPEL-macro direction recommended | |
| in [authority-library-evaluation.md](../authority-library-evaluation.md).) | |
| ## Current state | |
| - `mint` is split into `mint` + `mint_with_authority` (Option C) and all tests pass. | |
| - `set_authority` is **not** split yet; it currently has no signer marker (Option-B-style), so it | |
| works in tests (manual signing) but is not CLI-invokable. | |
| - The inline `Option<AccountId>` authority field and the removal of the `lez-authority` crate are | |
| settled and independent of whichever option we pick here. | |
| ## References | |
| - [authority-library-evaluation.md](../authority-library-evaluation.md) — why the authority is stored inline. | |
| - [LP-0013-README.md](../LP-0013-README.md) — the mint-authority model as implemented. | |
| - `lee/state_machine/src/validated_state_diff.rs` — the no-duplicate-accounts rule. | |
| - `spel-framework-macros` — `#[account(signer)]` validation + client signing behavior. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment