Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save 0x-r4bbit/ead1077de7b5ab6a5ed67b8b6a69f5bc to your computer and use it in GitHub Desktop.

Select an option

Save 0x-r4bbit/ead1077de7b5ab6a5ed67b8b6a69f5bc to your computer and use it in GitHub Desktop.
Design note: mint-authority signing model
# 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