diff --git a/.claude/skills/deploy-program/skill.md b/.claude/skills/deploy-program/skill.md deleted file mode 100644 index 7d926602..00000000 --- a/.claude/skills/deploy-program/skill.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Deploy a LEZ program to the sequencer. Use when the user asks to deploy, ship, or publish a program (e.g. "deploy the token program", "ship amm to the sequencer"). ---- - -# deploy-program - -Deploying a LEZ program is always a two-step process: compile first, then deploy. Never deploy -without rebuilding first — a stale binary deploys silently but won't reflect recent code changes. - -The program name corresponds to a top-level workspace directory. If none is specified, discover -available programs by looking for `/methods/guest/Cargo.toml` and ask the user to pick one. - -After deploying, confirm success by inspecting the binary and reporting the ProgramId to the user. - -## Gotchas - -- **Docker must be running.** `cargo risczero build` cross-compiles via Docker. Fail fast if not. -- **The output binary path follows a fixed convention** — derive it from the program name, don't guess. diff --git a/.claude/skills/program-id/skill.md b/.claude/skills/program-id/skill.md deleted file mode 100644 index 943ce310..00000000 --- a/.claude/skills/program-id/skill.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -description: Get the program ID (Image ID) for a LEZ program. Use when the user asks for a program's ID, image ID, or program address (e.g. "what's the token program id", "get the amm program id"). ---- - -# program-id - -The program ID is the RISC Zero Image ID derived from the compiled guest ELF binary. - -The program name corresponds to a top-level workspace directory. If none is specified, discover -available programs by looking for `/methods/guest/Cargo.toml` and ask the user to pick one. - -## Steps - -1. **Check if the binary exists** at `/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/.bin`. -2. **If missing, build it first** using `cargo risczero build --manifest-path /methods/guest/Cargo.toml`. - - Docker must be running for this step. Fail fast if not. -3. **Inspect the binary** with `spel-cli inspect ` and report the program ID to the user. diff --git a/Cargo.toml b/Cargo.toml index c48d05aa..6e24aac5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "lez-authority", "programs/token/core", "programs/token", "programs/token/methods", diff --git a/artifacts/amm-idl.json b/artifacts/amm-idl.json index a27829ea..d65d53b9 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -666,6 +666,12 @@ "type": { "option": "account_id" } + }, + { + "name": "authority", + "type": { + "defined": "Authority" + } } ] }, diff --git a/artifacts/ata-idl.json b/artifacts/ata-idl.json index 1222abee..5ccb597a 100644 --- a/artifacts/ata-idl.json +++ b/artifacts/ata-idl.json @@ -120,6 +120,12 @@ "type": { "option": "account_id" } + }, + { + "name": "authority", + "type": { + "defined": "Authority" + } } ] }, diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 24880613..c6c5f315 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -160,6 +160,12 @@ "type": { "option": "account_id" } + }, + { + "name": "authority", + "type": { + "defined": "Authority" + } } ] }, diff --git a/artifacts/token-idl.json b/artifacts/token-idl.json index 290e832f..59c150d4 100644 --- a/artifacts/token-idl.json +++ b/artifacts/token-idl.json @@ -49,6 +49,12 @@ { "name": "total_supply", "type": "u128" + }, + { + "name": "mint_authority", + "type": { + "option": "account_id" + } } ] }, @@ -144,6 +150,13 @@ "writable": true, "signer": false, "init": false + }, + { + "name": "authority_accounts", + "writable": false, + "signer": false, + "init": false, + "rest": true } ], "args": [ @@ -153,6 +166,32 @@ } ] }, + { + "name": "set_authority", + "accounts": [ + { + "name": "definition_account", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "authority_accounts", + "writable": false, + "signer": false, + "init": false, + "rest": true + } + ], + "args": [ + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, { "name": "print_nft", "accounts": [ @@ -194,6 +233,12 @@ "type": { "option": "account_id" } + }, + { + "name": "authority", + "type": { + "defined": "Authority" + } } ] }, diff --git a/artifacts/token.idl.json b/artifacts/token.idl.json new file mode 120000 index 00000000..9e0aff44 --- /dev/null +++ b/artifacts/token.idl.json @@ -0,0 +1 @@ +token-idl.json \ No newline at end of file diff --git a/docs/LP-0013-README.md b/docs/LP-0013-README.md new file mode 100644 index 00000000..b7380b29 --- /dev/null +++ b/docs/LP-0013-README.md @@ -0,0 +1,218 @@ +# LP-0013: Token Program Mint Authority + +This document describes the mint authority model added to the LEZ Token program as part of LP-0013. + +## Overview + +The LEZ Token program now supports a mint authority model for fungible tokens: + +- **Mint authority set at initialization** — create a token with a designated minter +- **Minting by the authority** — the authority can mint additional tokens at any time +- **Authority rotation** — transfer minting rights to a new key +- **Authority revocation** — permanently fix the supply by setting authority to `None` + +The `lez-authority` crate provides a reusable, program-agnostic authority library (RFP-001). + +## Architecture + +### Authority Model + +`mint_authority: Option<[u8; 32]>` is added to `TokenDefinition::Fungible`: +- `Some(key)` — the key holder can mint and rotate/revoke +- `None` — supply is permanently fixed, minting rejected + +### New Instructions + +| Instruction | Description | +|---|---| +| `NewFungibleDefinitionWithAuthority` | Create token with mint authority | +| `Mint` (updated) | Now authority-gated — Now authority-gated | +| `SetAuthority` | Rotate or revoke mint authority | + +### Atomicity + +`SetAuthority` only mutates state after all checks pass. A failed authorization check returns an error before any write occurs, leaving the prior authority intact. + +### Error Codes + +| Condition | Message | +|---|---| +| Mint when authority revoked | Mint authority check failed: Revoked | +| Mint by non-authority signer | Mint authority check failed: Unauthorized | +| Mint/SetAuthority without signed authority | Mint authority must authorize the transaction | +| SetAuthority on already-revoked | SetAuthority failed: AlreadyRevoked | +| SetAuthority by wrong signer | SetAuthority failed: Unauthorized | +| Create/rotate with all-zero authority | Mint authority must be a valid non-zero account ID | + +## Crate Structure + +- `lez-authority/` — Agnostic AuthoritySlot library (RFP-001) +- `programs/token/core/` — TokenDefinition with mint_authority field +- `programs/token/src/mint.rs` — Authority-gated minting +- `programs/token/src/set_authority.rs` — Rotation and revocation handler +- `programs/token/src/new_definition.rs` — NewFungibleDefinitionWithAuthority handler +- `programs/token/methods/guest/src/bin/token.rs` — Guest binary dispatch + +## Module/SDK + +`token_core` provides the reusable types and instructions for building Logos modules. It is already consumed by `amm`, `ata`, `stablecoin`, and `integration_tests` in this workspace: + +```toml +[dependencies] +token_core = { path = "programs/token/core" } +``` + +Key types: +- `TokenDefinition::Fungible { mint_authority, .. }` — token definition with authority +- `Instruction::NewFungibleDefinitionWithAuthority` — create with authority +- `Instruction::SetAuthority` — rotate or revoke + +## RFP-001 Compliance + +LP-0013 has a formal dependency on [RFP-001](https://github.com/logos-co/rfp/blob/master/RFPs/RFP-001-admin-authority-lib.md) — the standardised admin authority library. The `lez-authority` crate in this submission directly implements the approval pattern defined in RFP-001: + +| RFP-001 Requirement | How `lez-authority` satisfies it | +|---|---| +| Self-sufficient, agnostic authority library | `lez-authority` has zero program-specific dependencies — it only uses `borsh` for serialisation | +| Authority slot abstraction | `AuthoritySlot` struct wraps `Option<[u8; 32]>` with `check`, `set`, and revocation semantics | +| Approval check | `AuthoritySlot::check(signer)` returns an error if the signer does not match or authority is revoked | +| Rotation | `AuthoritySlot::set(Some(new_key))` atomically rotates to a new authority | +| Permanent revocation | `AuthoritySlot::set(None)` permanently fixes the supply — subsequent `set` calls are rejected | +| Reusable by other programs | Any LEZ program can add `lez-authority` as a workspace dependency and use `AuthoritySlot` directly | + +The `lez-authority` crate was also submitted as part of [RFP-001 PR #212](https://github.com/logos-co/spel/pull/212) (the `spel-admin-authority` library with the `#[require_admin]` macro). The two are complementary: `lez-authority` is the lightweight on-chain primitive; `spel-admin-authority` is the SPEL framework macro layer built on top of the same pattern. + +## Deployment + +The program ID is the hash of the compiled guest ELF and will change whenever +the guest is rebuilt. Obtain the current ID after building: + +```bash +lgs deploy --program-path target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin +``` + +### Build the guest binary + +```bash +cargo risczero build --manifest-path programs/token/methods/guest/Cargo.toml +``` + +### Deploy to the sequencer + +```bash +wallet deploy-program target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin +``` + +## Running Tests + +```bash +# Authority unit tests +cargo test -p lez-authority --lib +cargo test -p token_program --lib + +# Authority integration tests (zkVM, dev mode) +RISC0_DEV_MODE=1 cargo test -p integration_tests --test token -- token_new_fungible_definition_with_authority token_set_authority_revoke +``` + +## CLI Usage (via `spel`) + +### Create token with mint authority + +```bash +spel --idl artifacts/token-idl.json --program \ + -- new-fungible-definition-with-authority \ + --definition-target-account \ + --holding-target-account \ + --name "MyToken" \ + --initial-supply 1000000 \ + --mint-authority +``` + +### Mint tokens + +```bash +spel --idl artifacts/token-idl.json --program \ + -- mint \ + --definition-account \ + --authority-account \ + --user-holding-account \ + --amount-to-mint 500000 +``` + +### Rotate authority + +```bash +spel --idl artifacts/token-idl.json --program \ + -- set-authority \ + --definition-account \ + --authority-account \ + --new-authority +``` + +### Revoke authority (fix supply permanently) + +```bash +spel --idl artifacts/token-idl.json --program \ + -- set-authority \ + --definition-account \ + --authority-account \ + --new-authority none +``` + +## Example Scripts + +```bash +# Fixed supply token (creates with authority, then revokes) +bash scripts/examples/fixed_supply_token.sh + +# Variable supply token (creates with authority, mints more, optionally rotates) +bash scripts/examples/variable_supply_token.sh +``` + +## End-to-End Demo + +The demo script must be run from inside an `lgs` scaffold project directory (where the localnet and wallet live): + +```bash +# 1. Set up an lgs scaffold (if you don't have one): +cargo install logos-scaffold +lgs new my-scaffold && cd my-scaffold +lgs setup +lgs localnet start +lgs wallet topup + +# 2. Deploy the token program: +lgs deploy --program-path /path/to/lez-programs/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin + +# 3. Run the demo: +RISC0_DEV_MODE=0 bash /path/to/lez-programs/scripts/demo-full-flow.sh +``` + +The script will: +1. Verify the localnet is running +2. Fund the wallet +3. Create 3 token accounts (definition, supply holder, recipient) +4. Submit `NewFungibleDefinitionWithAuthority` (creates "DemoCoin" with 1M supply) +5. Submit `Mint` (mints 500K to recipient → total supply 1.5M) +6. Submit `SetAuthority` with `None` (permanently revokes minting) +7. Run unit tests to verify authority logic (64 tests) + +## Compute Unit (CU) Costs + +Measured on LEZ localnet with `RISC0_DEV_MODE=1` (execution only, no proof): + +| Operation | Execution Time | Notes | +|---|---|---| +| `NewFungibleDefinitionWithAuthority` | ~11ms | Creates token with mint authority | +| `Mint` (with authority) | ~10ms | Authority-gated mint | +| `SetAuthority` (rotate) | ~8ms | Rotates to new key | +| `SetAuthority` (revoke) | ~8ms | Permanently revokes, sets None | + +Note: With `RISC0_DEV_MODE=0`, full ZK proof generation takes 3–10 minutes per transaction on Apple M-series hardware. LEZ's per-transaction compute budget may change during testnet. + +## References + +- [lez-authority crate](../lez-authority/src/lib.rs) +- [SetAuthority handler](../programs/token/src/set_authority.rs) +- [Mint handler](../programs/token/src/mint.rs) +- [Solana SPL Token - Set Authority](https://solana.com/docs/tokens/basics/set-authority) diff --git a/lez-authority/Cargo.toml b/lez-authority/Cargo.toml new file mode 100644 index 00000000..7cef5269 --- /dev/null +++ b/lez-authority/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lez-authority" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" + +[lints] +workspace = true + +[dependencies] +borsh = { workspace = true } +serde = { workspace = true, features = ["derive"] } diff --git a/lez-authority/src/lib.rs b/lez-authority/src/lib.rs new file mode 100644 index 00000000..4efa7c51 --- /dev/null +++ b/lez-authority/src/lib.rs @@ -0,0 +1,210 @@ +//! Agnostic admin/mint authority library for LEZ programs. +//! Implements the approval model defined in RFP-001. +//! No dependency on any specific program or nssa_core. + +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorityError { + /// The authority slot is empty (renounced); the resource is permanently fixed. + Revoked, + /// The signer does not match the current authority. + Unauthorized, + /// Attempted to act on an already-renounced authority. + AlreadyRevoked, +} + +impl core::fmt::Display for AuthorityError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Revoked => write!(f, "authority has been revoked; resource is fixed"), + Self::Unauthorized => write!(f, "signer is not the current authority"), + Self::AlreadyRevoked => write!(f, "authority already revoked; cannot set again"), + } + } +} + +/// An ownership/authority slot. `None` = permanently renounced (no further changes +/// or privileged actions are possible). +#[derive( + BorshSerialize, BorshDeserialize, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, +)] +pub struct Authority(Option<[u8; 32]>); + +impl Authority { + /// Create an authority owned by `owner`. + #[must_use] + pub fn new(owner: [u8; 32]) -> Self { + Self(Some(owner)) + } + + /// Create a permanently renounced authority (fixed resource). + #[must_use] + pub fn renounced() -> Self { + Self(None) + } + + /// The current authority key, or `None` if renounced. + #[must_use] + pub fn authority(&self) -> Option<[u8; 32]> { + self.0 + } + + /// Returns `true` if the authority has been permanently renounced. + #[must_use] + pub fn is_renounced(&self) -> bool { + self.0.is_none() + } + + /// Require that `signer` is the current authority. + pub fn require(&self, signer: [u8; 32]) -> Result<(), AuthorityError> { + match self.0 { + None => Err(AuthorityError::Revoked), + Some(auth) if auth != signer => Err(AuthorityError::Unauthorized), + Some(_) => Ok(()), + } + } + + /// Rotate to a new authority, or renounce with `None`. + /// Only mutates AFTER all checks pass (atomic). + pub fn rotate( + &mut self, + signer: [u8; 32], + new: Option<[u8; 32]>, + ) -> Result<(), AuthorityError> { + match self.0 { + None => Err(AuthorityError::AlreadyRevoked), + Some(auth) if auth != signer => Err(AuthorityError::Unauthorized), + Some(_) => { + self.0 = new; + Ok(()) + } + } + } +} + +/// A type that carries an [`Authority`] slot and can be guarded by it. +/// +/// Programs "inherit the owner slot" by embedding an [`Authority`] field in their +/// account type and implementing this trait; the default methods then provide the +/// standard require / transfer / renounce semantics. +pub trait Ownable { + fn authority(&self) -> &Authority; + fn authority_mut(&mut self) -> &mut Authority; + + /// Require that `signer` is the current owner. + fn require_owner(&self, signer: [u8; 32]) -> Result<(), AuthorityError> { + self.authority().require(signer) + } + + /// Transfer ownership to `new`, authorized by the current owner `signer`. + fn transfer_ownership( + &mut self, + signer: [u8; 32], + new: [u8; 32], + ) -> Result<(), AuthorityError> { + self.authority_mut().rotate(signer, Some(new)) + } + + /// Permanently renounce ownership, authorized by the current owner `signer`. + fn renounce_ownership(&mut self, signer: [u8; 32]) -> Result<(), AuthorityError> { + self.authority_mut().rotate(signer, None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALICE: [u8; 32] = [1u8; 32]; + const BOB: [u8; 32] = [2u8; 32]; + + #[test] + fn require_succeeds_for_correct_owner() { + assert!(Authority::new(ALICE).require(ALICE).is_ok()); + } + + #[test] + fn require_fails_unauthorized() { + assert_eq!( + Authority::new(ALICE).require(BOB), + Err(AuthorityError::Unauthorized) + ); + } + + #[test] + fn require_fails_when_renounced() { + assert_eq!( + Authority::renounced().require(ALICE), + Err(AuthorityError::Revoked) + ); + } + + #[test] + fn rotate_transfers_authority() { + let mut auth = Authority::new(ALICE); + auth.rotate(ALICE, Some(BOB)).unwrap(); + assert_eq!(auth.authority(), Some(BOB)); + assert_eq!(auth.require(ALICE), Err(AuthorityError::Unauthorized)); + } + + #[test] + fn rotate_renounces_permanently() { + let mut auth = Authority::new(ALICE); + auth.rotate(ALICE, None).unwrap(); + assert!(auth.is_renounced()); + assert_eq!( + auth.rotate(ALICE, Some(ALICE)), + Err(AuthorityError::AlreadyRevoked) + ); + } + + #[test] + fn wrong_owner_cannot_rotate_and_state_unchanged() { + let mut auth = Authority::new(ALICE); + assert_eq!( + auth.rotate(BOB, Some(BOB)), + Err(AuthorityError::Unauthorized) + ); + assert_eq!(auth.authority(), Some(ALICE)); + } + + #[test] + fn renounce_on_already_renounced_fails() { + let mut auth = Authority::renounced(); + assert_eq!( + auth.rotate(ALICE, None), + Err(AuthorityError::AlreadyRevoked) + ); + } + + // Ownable trait via a tiny embedding type. + struct Resource { + owner: Authority, + } + impl Ownable for Resource { + fn authority(&self) -> &Authority { + &self.owner + } + + fn authority_mut(&mut self) -> &mut Authority { + &mut self.owner + } + } + + #[test] + fn ownable_require_transfer_renounce() { + let mut r = Resource { + owner: Authority::new(ALICE), + }; + assert!(r.require_owner(ALICE).is_ok()); + assert_eq!(r.require_owner(BOB), Err(AuthorityError::Unauthorized)); + + r.transfer_ownership(ALICE, BOB).unwrap(); + assert!(r.require_owner(BOB).is_ok()); + + r.renounce_ownership(BOB).unwrap(); + assert!(r.authority().is_renounced()); + } +} diff --git a/programs/amm/Cargo.toml b/programs/amm/Cargo.toml index e985d274..951d50ca 100644 --- a/programs/amm/Cargo.toml +++ b/programs/amm/Cargo.toml @@ -12,3 +12,4 @@ clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.g amm_core = { path = "core" } token_core = { path = "../token/core" } twap_oracle_core = { path = "../twap_oracle/core" } +lez-authority = { path = "../../lez-authority" } diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index 02ce0207..a62eb4e7 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -7,7 +7,9 @@ use amm_core::{ compute_vault_pda_seed, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, MINIMUM_LIQUIDITY, }; + use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use lez_authority::Authority; use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, @@ -193,6 +195,7 @@ pub fn new_definition( &token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), total_supply: MINIMUM_LIQUIDITY, + mint_authority: Some(pool_definition_lp.account_id), }, ) .with_pda_seeds(vec![ @@ -206,8 +209,14 @@ pub fn new_definition( name: String::from("LP Token"), total_supply: MINIMUM_LIQUIDITY, metadata_id: None, + authority: Authority::new( + pool_definition_lp + .account_id + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }); - let call_token_lp_user = ChainedCall::new( token_program_id, vec![pool_lp_after_lock, user_holding_lp.clone()], diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 3878b5ee..1b9eb3a8 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -538,10 +538,11 @@ impl ChainedCallForTests { ChainedCall::new( TOKEN_PROGRAM_ID, - vec![pool_lp_auth, lp_lock_holding_auth], + vec![pool_lp_auth.clone(), lp_lock_holding_auth], &token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), total_supply: MINIMUM_LIQUIDITY, + mint_authority: Some(pool_lp_auth.account_id), }, ) .with_pda_seeds(vec![ @@ -872,6 +873,12 @@ impl AccountWithMetadataForTests { name: String::from("test"), total_supply: BalanceForTests::lp_supply_init(), metadata_id: None, + authority: token_core::Authority::new( + IdForTests::token_lp_definition_id() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), }, @@ -897,6 +904,12 @@ impl AccountWithMetadataForTests { name: String::from("LP Token"), total_supply: MINIMUM_LIQUIDITY, metadata_id: None, + authority: token_core::Authority::new( + IdForTests::token_lp_definition_id() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), }, @@ -914,6 +927,12 @@ impl AccountWithMetadataForTests { name: String::from("test"), total_supply: BalanceForTests::lp_supply_init(), metadata_id: None, + authority: token_core::Authority::new( + IdForTests::token_lp_definition_id() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), }, @@ -3263,6 +3282,7 @@ fn test_new_definition_lp_symmetric_amounts() { &token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), total_supply: MINIMUM_LIQUIDITY, + mint_authority: Some(pool_lp_auth.account_id), }, ) .with_pda_seeds(vec![ @@ -3365,6 +3385,7 @@ fn test_minimum_liquidity_lock_and_remove_all_user_lp() { &token_core::Instruction::NewFungibleDefinition { name: String::from("LP Token"), total_supply: MINIMUM_LIQUIDITY, + mint_authority: Some(pool_lp_auth.account_id), }, ) .with_pda_seeds(vec![ diff --git a/programs/ata/methods/guest/Cargo.lock b/programs/ata/methods/guest/Cargo.lock index e3b64b74..613892c5 100644 --- a/programs/ata/methods/guest/Cargo.lock +++ b/programs/ata/methods/guest/Cargo.lock @@ -1113,6 +1113,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -2047,6 +2055,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "lez-authority", "serde", "spel-framework-macros", ] diff --git a/programs/ata/src/tests.rs b/programs/ata/src/tests.rs index 595cfddb..25de01c8 100644 --- a/programs/ata/src/tests.rs +++ b/programs/ata/src/tests.rs @@ -41,6 +41,7 @@ fn definition_account() -> AccountWithMetadata { name: "TEST".to_string(), total_supply: 1000, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: nssa_core::account::Nonce(0), }, diff --git a/programs/integration_tests/tests/amm.rs b/programs/integration_tests/tests/amm.rs index c76502c0..549dc616 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -401,6 +401,7 @@ impl Accounts { name: String::from("test"), total_supply: Balances::token_a_supply(), metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -414,6 +415,7 @@ impl Accounts { name: String::from("test"), total_supply: Balances::token_b_supply(), metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -427,6 +429,12 @@ impl Accounts { name: String::from("LP Token"), total_supply: Balances::token_lp_supply(), metadata_id: None, + authority: token_core::Authority::new( + Ids::token_lp_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -705,6 +713,12 @@ impl Accounts { name: String::from("LP Token"), total_supply: Balances::token_lp_supply_add(), metadata_id: None, + authority: token_core::Authority::new( + Ids::token_lp_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -797,6 +811,12 @@ impl Accounts { name: String::from("LP Token"), total_supply: Balances::token_lp_supply_remove(), metadata_id: None, + authority: token_core::Authority::new( + Ids::token_lp_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -810,6 +830,12 @@ impl Accounts { name: String::from("LP Token"), total_supply: 0, metadata_id: None, + authority: token_core::Authority::new( + Ids::token_lp_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -902,6 +928,12 @@ impl Accounts { name: String::from("LP Token"), total_supply: Balances::lp_supply_init(), metadata_id: None, + authority: token_core::Authority::new( + Ids::token_lp_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -1390,6 +1422,7 @@ fn fungible_total_supply(account: &Account) -> u128 { name: _, total_supply, metadata_id: _, + authority: _, } = definition else { panic!("expected fungible token definition") diff --git a/programs/integration_tests/tests/ata.rs b/programs/integration_tests/tests/ata.rs index 243ccd36..50b09ed6 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -84,6 +84,7 @@ impl Accounts { name: String::from("Gold"), total_supply: 1_000_000_u128, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -121,6 +122,7 @@ impl Accounts { name: String::from("Foreign Gold"), total_supply: 1_000_000_u128, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -495,6 +497,7 @@ fn ata_burn() { name: String::from("Gold"), total_supply: 700_000_u128, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index c4cc2768..e26cbe93 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -108,6 +108,7 @@ impl Accounts { name: String::from("Gold"), total_supply: Balances::user_holding_init(), metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -133,6 +134,7 @@ impl Accounts { name: String::from("DAI"), total_supply: Balances::stablecoin_supply_init(), metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } diff --git a/programs/integration_tests/tests/token.rs b/programs/integration_tests/tests/token.rs index fd7bbf80..e350778f 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -28,6 +28,10 @@ impl Keys { fn recipient_key() -> PrivateKey { PrivateKey::try_new([12; 32]).expect("valid private key") } + + fn authority_key() -> PrivateKey { + PrivateKey::try_new([13; 32]).expect("valid private key") + } } impl Ids { @@ -50,6 +54,10 @@ impl Ids { fn recipient() -> AccountId { AccountId::from(&PublicKey::new_from_private_key(&Keys::recipient_key())) } + + fn authority() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&Keys::authority_key())) + } } impl Accounts { @@ -61,6 +69,12 @@ impl Accounts { name: String::from("Gold"), total_supply: 1_000_000_u128, metadata_id: None, + authority: token_core::Authority::new( + Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -74,6 +88,12 @@ impl Accounts { name: String::from("Gold"), total_supply: 1_000_000_u128, metadata_id: None, + authority: token_core::Authority::new( + Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + ), }), nonce: Nonce(0), } @@ -102,6 +122,15 @@ impl Accounts { nonce: Nonce(0), } } + + fn authority_init() -> Account { + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::default(), + nonce: Nonce(0), + } + } } fn deploy_token(state: &mut V03State) { @@ -118,6 +147,7 @@ fn state_for_token_tests() -> V03State { state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); state.force_insert_account(Ids::holder(), Accounts::holder_init()); state.force_insert_account(Ids::recipient(), Accounts::recipient_init()); + state.force_insert_account(Ids::authority(), Accounts::authority_init()); state } @@ -126,6 +156,7 @@ fn state_for_token_tests_without_recipient() -> V03State { deploy_token(&mut state); state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init()); state.force_insert_account(Ids::holder(), Accounts::holder_init()); + state.force_insert_account(Ids::authority(), Accounts::authority_init()); state } @@ -137,6 +168,7 @@ fn token_new_fungible_definition() { let instruction = token_core::Instruction::NewFungibleDefinition { name: String::from("Gold"), total_supply: 1_000_000_u128, + mint_authority: None, }; let message = public_transaction::Message::try_new( @@ -164,6 +196,7 @@ fn token_new_fungible_definition() { name: String::from("Gold"), total_supply: 1_000_000_u128, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(1), } @@ -415,6 +448,12 @@ fn token_burn() { name: String::from("Gold"), total_supply: 800_000_u128, metadata_id: None, + authority: token_core::Authority::new( + Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes") + ), }), nonce: Nonce(0), } @@ -464,6 +503,12 @@ fn token_mint() { name: String::from("Gold"), total_supply: 1_500_000_u128, metadata_id: None, + authority: token_core::Authority::new( + Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes") + ), }), nonce: Nonce(1), } @@ -585,6 +630,12 @@ fn token_mint_fresh_authorized_public_recipient() { name: String::from("Gold"), total_supply: 1_500_000_u128, metadata_id: None, + authority: token_core::Authority::new( + Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes") + ), }), nonce: Nonce(1), } @@ -915,3 +966,247 @@ fn token_deshielded_transfer() { .get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account)) .is_some()); } + +#[test] +fn token_new_fungible_definition_with_authority() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_token(&mut state); + let authority_key: [u8; 32] = Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("AuthCoin"), + total_supply: 1_000_000_u128, + mint_authority: Some(AccountId::new(authority_key)), + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder()], + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::def_key(), &Keys::holder_key()], + ); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("AuthCoin"), + total_supply: 1_000_000_u128, + metadata_id: None, + authority: token_core::Authority::new(authority_key), + }), + nonce: Nonce(1), + } + ); +} + +#[test] +fn token_set_authority_revoke() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_token(&mut state); + let authority_key: [u8; 32] = Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + // Create token with authority + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("AuthCoin"), + total_supply: 1_000_000_u128, + mint_authority: Some(AccountId::new(authority_key)), + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder()], + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::def_key(), &Keys::holder_key()], + ); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + // Seed the authority account so it can sign the revoke + state.force_insert_account(Ids::authority(), Accounts::authority_init()); + + // Revoke authority + let instruction = token_core::Instruction::SetAuthority { + new_authority: None, + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition()], + vec![Nonce(1)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("AuthCoin"), + total_supply: 1_000_000_u128, + metadata_id: None, + authority: token_core::Authority::renounced(), + }), + nonce: Nonce(2), + } + ); +} + +/// Integration test for RFP-001 authority rotation flow: +/// 1. Create a token where `Ids::token_definition()` is the initial mint authority (self-authority). +/// 2. Rotate the mint authority to `Ids::authority()` (an external key). +/// 3. Verify that the new external authority can mint by presenting itself as a rest account. +/// 4. Verify that the OLD authority (def key) can no longer mint after rotation. +#[test] +fn token_rotate_authority_then_new_authority_can_mint() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + deploy_token(&mut state); + + let authority_key: [u8; 32] = Ids::authority() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + + // Step 1: Create token with self-authority (def account is initial mint authority). + let instruction = token_core::Instruction::NewFungibleDefinition { + name: String::from("RotCoin"), + total_supply: 1_000_000_u128, + mint_authority: Some(AccountId::new( + Ids::token_definition() + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"), + )), + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder()], + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::def_key(), &Keys::holder_key()], + ); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + // Step 2: Rotate mint authority from def_key to Ids::authority() (external key). + // Self-authority path: no rest accounts; def_key signs. + let instruction = token_core::Instruction::SetAuthority { + new_authority: Some(AccountId::new(authority_key)), + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition()], + vec![Nonce(1)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + // Verify the authority slot now holds Ids::authority(). + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("RotCoin"), + total_supply: 1_000_000_u128, + metadata_id: None, + authority: token_core::Authority::new(authority_key), + }), + nonce: Nonce(2), + } + ); + + // Seed the external authority account and the holder so they exist in state. + state.force_insert_account(Ids::authority(), Accounts::authority_init()); + state.force_insert_account(Ids::holder(), Accounts::holder_init()); + + // Step 3: New external authority mints by presenting itself as a rest account. + // mint accounts: [definition_account, holder_account, ...authority_accounts] + let instruction = token_core::Instruction::Mint { + amount_to_mint: 500_000_u128, + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder(), Ids::authority()], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = + public_transaction::WitnessSet::for_message(&message, &[&Keys::authority_key()]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + // Verify total_supply increased and holder balance reflects the mint. + assert_eq!( + state.get_account_by_id(Ids::token_definition()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("RotCoin"), + total_supply: 1_500_000_u128, + metadata_id: None, + authority: token_core::Authority::new(authority_key), + }), + nonce: Nonce(2), + } + ); + assert_eq!( + state.get_account_by_id(Ids::holder()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 1_500_000_u128, + }), + nonce: Nonce(0), + } + ); + + // Step 4: OLD authority (def_key self-authority path) must be rejected after rotation. + let instruction = token_core::Instruction::Mint { + amount_to_mint: 1_u128, + }; + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::holder()], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]); + let tx = PublicTransaction::new(message, witness_set); + let result = state.transition_from_public_transaction(&tx, 0, 0); + assert!( + result.is_err(), + "Old authority must be rejected after rotation" + ); +} diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index 81ba492e..74f088c8 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -1602,6 +1602,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -2865,6 +2873,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "lez-authority", "serde", "spel-framework-macros", ] diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 41e01544..8f4ac0c8 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -79,6 +79,7 @@ fn collateral_definition_account() -> AccountWithMetadata { name: "SNT".to_owned(), total_supply: 1_000_000, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), }, @@ -156,6 +157,7 @@ fn stablecoin_definition_account() -> AccountWithMetadata { name: "DAI".to_owned(), total_supply: 1_000_000, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), }, @@ -389,6 +391,7 @@ fn open_position_rejects_mismatched_token_definition() { name: "OTHER".to_owned(), total_supply: 1, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), }, diff --git a/programs/token/Cargo.toml b/programs/token/Cargo.toml index ee016ac5..f96aafcb 100644 --- a/programs/token/Cargo.toml +++ b/programs/token/Cargo.toml @@ -9,3 +9,4 @@ workspace = true [dependencies] nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc6", features = ["host"], package = "lee_core" } token_core = { path = "core" } +lez-authority = { path = "../../lez-authority" } diff --git a/programs/token/core/Cargo.toml b/programs/token/core/Cargo.toml index e3983245..76f4070c 100644 --- a/programs/token/core/Cargo.toml +++ b/programs/token/core/Cargo.toml @@ -11,3 +11,4 @@ nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.gi spel-framework-macros = { git = "https://github.com/0x-r4bbit/spel.git", rev = "91023c9115bf88173b0d25d2e905f2a55ef0313b", package = "spel-framework-macros" } borsh = { version = "1.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } +lez-authority = { path = "../../../lez-authority" } diff --git a/programs/token/core/src/lib.rs b/programs/token/core/src/lib.rs index 3954537a..c0bff3f2 100644 --- a/programs/token/core/src/lib.rs +++ b/programs/token/core/src/lib.rs @@ -1,6 +1,7 @@ //! This crate contains core data structures and utilities for the Token Program. use borsh::{BorshDeserialize, BorshSerialize}; +pub use lez_authority::{Authority, Ownable}; use nssa_core::account::{AccountId, Data}; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; @@ -18,10 +19,18 @@ pub enum Instruction { /// Create a new fungible token definition without metadata. /// + /// `mint_authority` decides the supply model: + /// - `Some(id)` — `id` may mint additional supply and rotate/renounce the authority, + /// - `None` — supply is permanently fixed at `total_supply`. + /// /// Required accounts: /// - Token Definition account (uninitialized, authorized), /// - Token Holding account (uninitialized, authorized). - NewFungibleDefinition { name: String, total_supply: u128 }, + NewFungibleDefinition { + name: String, + total_supply: u128, + mint_authority: Option, + }, /// Create a new fungible or non-fungible token definition with metadata. /// @@ -51,10 +60,14 @@ pub enum Instruction { /// Mint new tokens to the holder's account. /// + /// Minting is gated on the definition's mint authority: the Token Definition + /// account must be authorized in this transaction and its account id must match + /// the stored authority. A definition with no authority has a fixed supply and + /// rejects minting. + /// /// Required accounts: - /// - Token Definition account (initialized, authorized), - /// - Token Holding account (initialized, or uninitialized with holder authorization in the - /// same transaction). + /// - Token Definition account (initialized, authorized as the current mint authority), + /// - Token Holding account (uninitialized or authorized and initialized). Mint { amount_to_mint: u128 }, /// Print a new NFT from the master copy. @@ -63,6 +76,13 @@ pub enum Instruction { /// - NFT Master Token Holding account (authorized), /// - NFT Printed Copy Token Holding account (uninitialized, authorized). PrintNft, + + /// Rotate or renounce the mint authority for a fungible token definition. + /// Pass `new_authority: None` to permanently renounce minting (fixed supply). + /// + /// Required accounts: + /// - Token Definition account (initialized, authorized as the current mint authority). + SetAuthority { new_authority: Option }, } #[derive(Serialize, Deserialize)] @@ -70,6 +90,9 @@ pub enum NewTokenDefinition { Fungible { name: String, total_supply: u128, + /// Mint authority. `Some(id)` makes the token mintable by `id`; `None` + /// fixes the supply. + mint_authority: Option, }, NonFungible { name: String, @@ -84,6 +107,9 @@ pub enum TokenDefinition { name: String, total_supply: u128, metadata_id: Option, + /// Mint authority slot. `Some(id)` may mint and rotate/renounce; + /// `None` means the supply is permanently fixed. + authority: Authority, }, NonFungible { name: String, @@ -92,6 +118,26 @@ pub enum TokenDefinition { }, } +impl Ownable for TokenDefinition { + fn authority(&self) -> &Authority { + match self { + TokenDefinition::Fungible { authority, .. } => authority, + TokenDefinition::NonFungible { .. } => { + panic!("Authority is not supported for Non-Fungible Tokens") + } + } + } + + fn authority_mut(&mut self) -> &mut Authority { + match self { + TokenDefinition::Fungible { authority, .. } => authority, + TokenDefinition::NonFungible { .. } => { + panic!("Authority is not supported for Non-Fungible Tokens") + } + } + } +} + impl TryFrom<&Data> for TokenDefinition { type Error = std::io::Error; diff --git a/programs/token/methods/guest/Cargo.lock b/programs/token/methods/guest/Cargo.lock index a31d2532..28a16cde 100644 --- a/programs/token/methods/guest/Cargo.lock +++ b/programs/token/methods/guest/Cargo.lock @@ -1079,6 +1079,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -2027,6 +2035,7 @@ version = "0.1.0" dependencies = [ "borsh", "lee_core", + "lez-authority", "serde", "spel-framework-macros", ] @@ -2036,6 +2045,7 @@ name = "token_program" version = "0.1.0" dependencies = [ "lee_core", + "lez-authority", "token_core", ] diff --git a/programs/token/methods/guest/src/bin/token.rs b/programs/token/methods/guest/src/bin/token.rs index 4b0e3507..740a843e 100644 --- a/programs/token/methods/guest/src/bin/token.rs +++ b/programs/token/methods/guest/src/bin/token.rs @@ -1,8 +1,8 @@ #![cfg_attr(not(test), no_main)] -use spel_framework::prelude::*; +use nssa_core::account::{AccountId, AccountWithMetadata}; use spel_framework::context::ProgramContext; -use nssa_core::account::AccountWithMetadata; +use spel_framework::prelude::*; #[cfg(not(test))] risc0_zkvm::guest::entry!(main); @@ -25,15 +25,15 @@ mod token { recipient: AccountWithMetadata, amount_to_transfer: u128, ) -> SpelResult { - Ok(spel_framework::SpelOutput::execute(token_program::transfer::transfer( - sender, - recipient, - amount_to_transfer, - ), vec![])) + Ok(spel_framework::SpelOutput::execute( + token_program::transfer::transfer(sender, recipient, amount_to_transfer), + vec![], + )) } /// Create a new fungible token definition without metadata. /// Definition and holding targets must be uninitialized and authorized. + /// `mint_authority` is `Some(id)` for a mintable token or `None` for fixed supply. #[instruction] pub fn new_fungible_definition( #[account(init, signer)] @@ -42,6 +42,7 @@ mod token { holding_target_account: AccountWithMetadata, name: String, total_supply: u128, + mint_authority: Option, ) -> SpelResult { Ok(spel_framework::SpelOutput::execute( token_program::new_definition::new_fungible_definition( @@ -49,6 +50,7 @@ mod token { holding_target_account, name, total_supply, + mint_authority, ), vec![], )) @@ -111,30 +113,55 @@ mod token { user_holding_account: AccountWithMetadata, amount_to_burn: u128, ) -> SpelResult { - Ok(spel_framework::SpelOutput::execute(token_program::burn::burn( - definition_account, - user_holding_account, - amount_to_burn, - ), vec![])) + Ok(spel_framework::SpelOutput::execute( + token_program::burn::burn(definition_account, user_holding_account, amount_to_burn), + vec![], + )) } /// Mint new tokens to the holder's account. + /// The current mint authority must authorize the transaction: either the definition account itself when `authority_accounts` is empty (self/PDA authority), or an external authority account passed as the first rest account after rotation. /// Fresh public holders must be explicitly authorized in the same transaction. #[instruction] pub fn mint( ctx: ProgramContext, #[account(mut, signer)] definition_account: AccountWithMetadata, - #[account(mut)] user_holding_account: AccountWithMetadata, + authority_accounts: Vec, amount_to_mint: u128, ) -> SpelResult { - Ok(spel_framework::SpelOutput::execute(token_program::mint::mint( - definition_account, - user_holding_account, - amount_to_mint, - ctx.self_program_id, - ), vec![])) + Ok(spel_framework::SpelOutput::execute( + token_program::mint::mint( + definition_account, + user_holding_account, + amount_to_mint, + authority_accounts, + ctx.self_program_id, + ), + vec![], + )) + } + + /// Rotate or renounce the mint authority for a fungible token definition. + /// Pass `new_authority: None` to permanently renounce minting (fixed supply). + /// The current mint authority must authorize the transaction: either the definition account itself when `authority_accounts` is empty (self/PDA authority), or an external authority account passed as the first rest account after rotation. + #[instruction] + pub fn set_authority( + ctx: ProgramContext, + definition_account: AccountWithMetadata, + authority_accounts: Vec, + new_authority: Option, + ) -> SpelResult { + Ok(spel_framework::SpelOutput::execute( + token_program::set_authority::set_authority( + definition_account, + new_authority, + authority_accounts, + ctx.self_program_id, + ), + vec![], + )) } /// Print a new NFT from the master copy. @@ -146,9 +173,9 @@ mod token { #[account(init, signer)] printed_account: AccountWithMetadata, ) -> SpelResult { - Ok(spel_framework::SpelOutput::execute(token_program::print_nft::print_nft( - master_account, - printed_account, - ), vec![])) + Ok(spel_framework::SpelOutput::execute( + token_program::print_nft::print_nft(master_account, printed_account), + vec![], + )) } } diff --git a/programs/token/src/burn.rs b/programs/token/src/burn.rs index 94637d92..e9847456 100644 --- a/programs/token/src/burn.rs +++ b/programs/token/src/burn.rs @@ -31,6 +31,7 @@ pub fn burn( name: _, metadata_id: _, total_supply, + authority: _, }, TokenHolding::Fungible { definition_id: _, diff --git a/programs/token/src/lib.rs b/programs/token/src/lib.rs index 8b0698c5..b0d1361e 100644 --- a/programs/token/src/lib.rs +++ b/programs/token/src/lib.rs @@ -7,6 +7,7 @@ pub mod initialize; pub mod mint; pub mod new_definition; pub mod print_nft; +pub mod set_authority; pub mod transfer; mod tests; diff --git a/programs/token/src/mint.rs b/programs/token/src/mint.rs index 0c638d12..b38c5724 100644 --- a/programs/token/src/mint.rs +++ b/programs/token/src/mint.rs @@ -1,3 +1,4 @@ +use lez_authority::Ownable; use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, Claim, ProgramId}, @@ -8,12 +9,9 @@ pub fn mint( definition_account: AccountWithMetadata, user_holding_account: AccountWithMetadata, amount_to_mint: u128, + authority_accounts: Vec, token_program_id: ProgramId, ) -> Vec { - assert!( - definition_account.is_authorized, - "Definition authorization is missing" - ); assert_eq!( definition_account.account.program_owner, token_program_id, "Token definition must be owned by token program" @@ -21,6 +19,32 @@ pub fn mint( let mut definition = TokenDefinition::try_from(&definition_account.account.data) .expect("Token Definition account must be valid"); + + // Minting is gated on the definition's stored mint authority. The proof of + // authority is whichever account is presented as authorized AND whose id + // matches the stored authority: + // + // - When `authority_accounts` is empty, the definition account itself must be the authority + // (self/PDA authority — e.g. the AMM's LP definition minting under its own seed). This is the + // original mint behavior. + // - When `authority_accounts` has one entry, that account is the external authority (e.g. a + // rotated owner key). This lets a transferred authority actually mint, as RFP-001 requires. + if let TokenDefinition::Fungible { .. } = &definition { + let authority = authority_accounts.first().unwrap_or(&definition_account); + assert!( + authority.is_authorized, + "Mint authority must authorize the transaction" + ); + let signer: [u8; 32] = authority + .account_id + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + definition + .require_owner(signer) + .expect("Mint authority check failed"); + } + let mut holding = if user_holding_account.account == Account::default() { TokenHolding::zeroized_from_definition(definition_account.account_id, &definition) } else { @@ -40,6 +64,7 @@ pub fn mint( name: _, metadata_id: _, total_supply, + authority: _, }, TokenHolding::Fungible { definition_id: _, @@ -69,8 +94,17 @@ pub fn mint( let mut holding_post = user_holding_account.account; holding_post.data = Data::from(&holding); - vec![ - AccountPostState::new(definition_post), - AccountPostState::new_claimed_if_default(holding_post, Claim::Authorized), - ] + // Post-states must match pre-state order and count. Pre-state order is + // [definition, holding, ...authority_accounts]; authority accounts are + // read-only and pass through unchanged. + let mut post_states = Vec::with_capacity(authority_accounts.len().saturating_add(2)); + post_states.push(AccountPostState::new(definition_post)); + post_states.push(AccountPostState::new_claimed_if_default( + holding_post, + Claim::Authorized, + )); + for authority in authority_accounts { + post_states.push(AccountPostState::new(authority.account)); + } + post_states } diff --git a/programs/token/src/new_definition.rs b/programs/token/src/new_definition.rs index 91967a03..2298f3d9 100644 --- a/programs/token/src/new_definition.rs +++ b/programs/token/src/new_definition.rs @@ -1,16 +1,39 @@ +use lez_authority::Authority; use nssa_core::{ - account::{Account, AccountWithMetadata, Data}, + account::{Account, AccountId, AccountWithMetadata, Data}, program::{AccountPostState, Claim}, }; use token_core::{ NewTokenDefinition, NewTokenMetadata, TokenDefinition, TokenHolding, TokenMetadata, }; +/// Build the embedded [`Authority`] for a freshly created fungible definition. +/// +/// `Some(id)` makes the token mintable by `id`; `None` fixes the supply. +/// An all-zero authority id is rejected as it cannot be a real signer. +fn authority_from(mint_authority: Option) -> Authority { + match mint_authority { + Some(id) => { + let key: [u8; 32] = id + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + assert!( + key != [0u8; 32], + "Mint authority must be a valid non-zero account ID" + ); + Authority::new(key) + } + None => Authority::renounced(), + } +} + pub fn new_fungible_definition( definition_target_account: AccountWithMetadata, holding_target_account: AccountWithMetadata, name: String, total_supply: u128, + mint_authority: Option, ) -> Vec { assert_eq!( definition_target_account.account, @@ -36,6 +59,7 @@ pub fn new_fungible_definition( name, total_supply, metadata_id: None, + authority: authority_from(mint_authority), }; let token_holding = TokenHolding::Fungible { definition_id: definition_target_account.account_id, @@ -92,11 +116,16 @@ pub fn new_definition_with_metadata( ); let (token_definition, token_holding) = match new_definition { - NewTokenDefinition::Fungible { name, total_supply } => ( + NewTokenDefinition::Fungible { + name, + total_supply, + mint_authority, + } => ( TokenDefinition::Fungible { name, total_supply, metadata_id: Some(metadata_target_account.account_id), + authority: authority_from(mint_authority), }, TokenHolding::Fungible { definition_id: definition_target_account.account_id, @@ -124,7 +153,7 @@ pub fn new_definition_with_metadata( standard: metadata.standard, uri: metadata.uri, creators: metadata.creators, - primary_sale_date: 0u64, // TODO #261: future works to implement this + primary_sale_date: 0u64, }; let mut definition_target_account_post = definition_target_account.account.clone(); diff --git a/programs/token/src/set_authority.rs b/programs/token/src/set_authority.rs new file mode 100644 index 00000000..2f6e9e38 --- /dev/null +++ b/programs/token/src/set_authority.rs @@ -0,0 +1,75 @@ +use lez_authority::Ownable; +use nssa_core::{ + account::{AccountId, AccountWithMetadata, Data}, + program::{AccountPostState, ProgramId}, +}; +use token_core::TokenDefinition; + +pub fn set_authority( + definition_account: AccountWithMetadata, + new_authority: Option, + authority_accounts: Vec, + token_program_id: ProgramId, +) -> Vec { + assert_eq!( + definition_account.account.program_owner, token_program_id, + "Token definition must be owned by token program" + ); + + let mut definition = TokenDefinition::try_from(&definition_account.account.data) + .expect("Token Definition account must be valid"); + + match &mut definition { + TokenDefinition::Fungible { .. } => { + // The current mint authority must authorize this transaction. As in + // `mint`, the proof is either the definition account itself (empty + // `authority_accounts`, self/PDA authority) or an explicit external + // authority account (one entry), so a rotated authority can act. + let authority = authority_accounts.first().unwrap_or(&definition_account); + assert!( + authority.is_authorized, + "Mint authority must authorize the transaction" + ); + let signer: [u8; 32] = authority + .account_id + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + + match new_authority { + Some(new) => { + let new_key: [u8; 32] = new + .as_ref() + .try_into() + .expect("AccountId is always 32 bytes"); + assert!( + new_key != [0u8; 32], + "New mint authority must be a valid non-zero account ID" + ); + definition + .transfer_ownership(signer, new_key) + .expect("SetAuthority failed"); + } + None => { + definition + .renounce_ownership(signer) + .expect("SetAuthority failed"); + } + } + } + TokenDefinition::NonFungible { .. } => { + panic!("SetAuthority is not supported for Non-Fungible Tokens"); + } + } + + let mut definition_post = definition_account.account; + definition_post.data = Data::from(&definition); + + // Post-states match pre-state order/count: [definition, ...authority_accounts]. + let mut post_states = Vec::with_capacity(authority_accounts.len().saturating_add(1)); + post_states.push(AccountPostState::new(definition_post)); + for authority in authority_accounts { + post_states.push(AccountPostState::new(authority.account)); + } + post_states +} diff --git a/programs/token/src/tests.rs b/programs/token/src/tests.rs index 2df8e5cc..c8f90803 100644 --- a/programs/token/src/tests.rs +++ b/programs/token/src/tests.rs @@ -18,6 +18,7 @@ use crate::{ mint::mint, new_definition::{new_definition_with_metadata, new_fungible_definition}, print_nft::print_nft, + set_authority::set_authority, transfer::transfer, }; @@ -42,6 +43,7 @@ impl AccountForTests { name: String::from("test"), total_supply: BalanceForTests::init_supply(), metadata_id: None, + authority: lez_authority::Authority::new([15_u8; 32]), }), nonce: Nonce(0), }, @@ -59,6 +61,7 @@ impl AccountForTests { name: String::from("test"), total_supply: BalanceForTests::init_supply(), metadata_id: None, + authority: lez_authority::Authority::renounced(), }), nonce: Nonce(0), }, @@ -76,6 +79,7 @@ impl AccountForTests { name: String::from("test"), total_supply: BalanceForTests::init_supply(), metadata_id: None, + authority: lez_authority::Authority::renounced(), }), nonce: Nonce(0), }, @@ -157,6 +161,7 @@ impl AccountForTests { name: String::from("test"), total_supply: BalanceForTests::init_supply_burned(), metadata_id: None, + authority: lez_authority::Authority::new([15_u8; 32]), }), nonce: Nonce(0), }, @@ -238,6 +243,7 @@ impl AccountForTests { name: String::from("test"), total_supply: BalanceForTests::init_supply_mint(), metadata_id: None, + authority: lez_authority::Authority::new([15_u8; 32]), }), nonce: Nonce(0), }, @@ -328,6 +334,7 @@ impl AccountForTests { name: String::from("test"), total_supply: BalanceForTests::init_supply(), metadata_id: None, + authority: lez_authority::Authority::renounced(), }), nonce: Nonce(0), }, @@ -594,6 +601,7 @@ fn test_new_definition_non_default_first_account_should_fail() { holding_account, String::from("test"), 10, + None, ); } @@ -618,6 +626,7 @@ fn test_new_definition_non_default_second_account_should_fail() { holding_account, String::from("test"), 10, + None, ); } @@ -631,6 +640,7 @@ fn test_new_definition_requires_authorized_definition_target() { holding_account, String::from("test"), 10, + None, ); } @@ -644,6 +654,7 @@ fn test_new_definition_requires_authorized_holding_target() { holding_account, String::from("test"), 10, + None, ); } @@ -657,6 +668,7 @@ fn test_new_definition_with_valid_inputs_succeeds() { holding_account, String::from("test"), BalanceForTests::init_supply(), + None, ); let [definition_account, holding_account] = post_states.try_into().unwrap(); @@ -900,6 +912,7 @@ fn test_mint_not_valid_holding_account() { definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); } @@ -913,19 +926,23 @@ fn test_mint_not_valid_definition_account() { definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); } #[test] -#[should_panic(expected = "Definition authorization is missing")] +#[should_panic(expected = "Mint authority must authorize the transaction")] fn test_mint_missing_authorization() { - let definition_account = AccountForTests::definition_account_without_auth(); + // The definition account itself is the authority; mark it unauthorized. + let mut definition_account = AccountForTests::definition_account_auth(); + definition_account.is_authorized = false; let holding_account = AccountForTests::holding_same_definition_without_authorization(); let _post_states = mint( definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); } @@ -939,6 +956,21 @@ fn test_mint_rejects_foreign_owned_definition() { definition_account, holding_account, BalanceForTests::mint_success(), + vec![], + TOKEN_PROGRAM_ID, + ); +} + +#[test] +#[should_panic(expected = "Token definition must be owned by token program")] +fn test_set_authority_rejects_foreign_owned_definition() { + // A foreign-owned account carrying token-shaped data must not be able to + // rotate or revoke its authority through the token program. + let definition_account = AccountForTests::definition_account_foreign_owner(); + let _post_states = set_authority( + definition_account, + Some(AccountId::new([7_u8; 32])), + vec![], TOKEN_PROGRAM_ID, ); } @@ -946,12 +978,14 @@ fn test_mint_rejects_foreign_owned_definition() { #[test] #[should_panic(expected = "Mismatch Token Definition and Token Holding")] fn test_mint_mismatched_token_definition() { + // let definition_account = AccountForTests::definition_account_auth(); let holding_account = AccountForTests::holding_different_definition(); let _post_states = mint( definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); } @@ -964,6 +998,7 @@ fn test_mint_success() { definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); @@ -989,6 +1024,7 @@ fn test_mint_uninit_holding_success() { definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); @@ -1015,6 +1051,7 @@ fn test_mint_total_supply_overflow() { definition_account, holding_account, BalanceForTests::mint_overflow(), + vec![], TOKEN_PROGRAM_ID, ); } @@ -1028,6 +1065,7 @@ fn test_mint_holding_account_overflow() { definition_account, holding_account, BalanceForTests::mint_overflow(), + vec![], TOKEN_PROGRAM_ID, ); } @@ -1041,6 +1079,7 @@ fn test_mint_cannot_mint_unmintable_tokens() { definition_account, holding_account, BalanceForTests::mint_success(), + vec![], TOKEN_PROGRAM_ID, ); } @@ -1053,6 +1092,7 @@ fn test_new_definition_with_metadata_success() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1074,6 +1114,42 @@ fn test_new_definition_with_metadata_success() { assert_eq!(metadata_post.required_claim(), Some(Claim::Authorized)); } +/// Comment #2: a metadata-backed fungible created with `mint_authority: Some(..)` +/// carries a real, non-renounced authority and is therefore mintable — no longer +/// force-fixed-supply the way the hardcoded `Authority::renounced()` made it. +#[test] +fn test_metadata_fungible_with_authority_is_mintable() { + let definition_account = AccountForTests::definition_account_uninit_auth(); + let holding_account = AccountForTests::holding_account_uninit_auth(); + let metadata_account = AccountForTests::metadata_account_uninit_auth(); + let new_definition = NewTokenDefinition::Fungible { + name: String::from("test"), + total_supply: 15u128, + mint_authority: Some(AccountId::new([15_u8; 32])), + }; + let metadata = NewTokenMetadata { + standard: MetadataStandard::Simple, + uri: "test_uri".to_string(), + creators: "test_creators".to_string(), + }; + let post_states = new_definition_with_metadata( + definition_account, + holding_account, + metadata_account, + new_definition, + metadata, + ); + let [definition_post, _holding_post, _metadata_post] = post_states.try_into().unwrap(); + + // The stored authority must be the requested key, NOT renounced. + let def = TokenDefinition::try_from(&definition_post.account().data).unwrap(); + let stored = match def { + TokenDefinition::Fungible { authority, .. } => authority.authority(), + _ => None, + }; + assert_eq!(stored, Some([15_u8; 32])); +} + #[should_panic(expected = "Definition target account must be authorized")] #[test] fn test_call_new_definition_metadata_requires_authorized_definition() { @@ -1083,6 +1159,7 @@ fn test_call_new_definition_metadata_requires_authorized_definition() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1107,6 +1184,7 @@ fn test_call_new_definition_metadata_requires_authorized_holding() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1135,6 +1213,7 @@ fn test_call_new_definition_metadata_requires_authorized_metadata() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1167,6 +1246,7 @@ fn test_call_new_definition_metadata_with_init_definition() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1199,6 +1279,7 @@ fn test_call_new_definition_metadata_with_init_metadata() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1231,6 +1312,7 @@ fn test_call_new_definition_metadata_with_init_holding() { let new_definition = NewTokenDefinition::Fungible { name: String::from("test"), total_supply: 15u128, + mint_authority: None, }; let metadata = NewTokenMetadata { standard: MetadataStandard::Simple, @@ -1313,3 +1395,329 @@ fn test_print_nft_success() { assert_eq!(post_master_nft.required_claim(), None); assert_eq!(post_printed.required_claim(), Some(Claim::Authorized)); } + +#[cfg(test)] +mod authority_tests { + use super::*; + use crate::{mint::mint, set_authority::set_authority}; + + const AUTHORITY: [u8; 32] = [15_u8; 32]; + const TOKEN_PROGRAM_ID: [u32; 8] = [5_u32; 8]; + + /// A fungible definition whose own account id ([15;32]) equals its stored + /// mint authority, authorized in the transaction. This models both an external + /// owner signing the definition key and a PDA authorized via its seeds. + fn def_with_authority() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [5_u32; 8], + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply: 100_000_u128, + metadata_id: None, + authority: lez_authority::Authority::new(AUTHORITY), + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: AccountId::new([15; 32]), + } + } + + /// A definition whose authority has been renounced (fixed supply). + fn def_with_authority_revoked() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [5_u32; 8], + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply: 100_000_u128, + metadata_id: None, + authority: lez_authority::Authority::renounced(), + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: AccountId::new([15; 32]), + } + } + + /// A definition whose account id ([99;32]) does NOT match its stored + /// authority ([15;32]) — models a caller that isn't the current authority. + fn def_wrong_authority() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [5_u32; 8], + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply: 100_000_u128, + metadata_id: None, + authority: lez_authority::Authority::new(AUTHORITY), + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: AccountId::new([99; 32]), + } + } + + fn holding_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: [5_u32; 8], + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: AccountId::new([15; 32]), + balance: 1_000_u128, + }), + nonce: 0_u128.into(), + }, + is_authorized: false, + account_id: AccountId::new([17; 32]), + } + } + + #[test] + fn mint_with_authority_succeeds() { + let post_states = mint( + def_with_authority(), + holding_account(), + 50_000, + vec![], + TOKEN_PROGRAM_ID, + ); + let [def_post, holding_post] = post_states.try_into().unwrap(); + + let def = TokenDefinition::try_from(&def_post.account().data).unwrap(); + let holding = TokenHolding::try_from(&holding_post.account().data).unwrap(); + + assert!(matches!( + def, + TokenDefinition::Fungible { + total_supply: 150_000, + .. + } + )); + assert!(matches!( + holding, + TokenHolding::Fungible { + balance: 51_000, + .. + } + )); + } + + #[test] + #[should_panic(expected = "Mint authority check failed")] + fn mint_with_revoked_authority_fails() { + let _ = mint( + def_with_authority_revoked(), + holding_account(), + 50_000, + vec![], + TOKEN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "Mint authority must authorize the transaction")] + fn mint_without_is_authorized_fails() { + let mut def = def_with_authority(); + def.is_authorized = false; + let _ = mint(def, holding_account(), 50_000, vec![], TOKEN_PROGRAM_ID); + } + + #[test] + #[should_panic(expected = "Mint authority check failed")] + fn mint_with_wrong_signer_fails() { + let _ = mint( + def_wrong_authority(), + holding_account(), + 50_000, + vec![], + TOKEN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "New mint authority must be a valid non-zero account ID")] + fn set_authority_rejects_zero_new_authority() { + let _ = set_authority( + def_with_authority(), + Some(AccountId::new([0u8; 32])), + vec![], + TOKEN_PROGRAM_ID, + ); + } + + #[test] + fn set_authority_rotates_to_new_key() { + let new_key = AccountId::new([7_u8; 32]); + let post_states = set_authority( + def_with_authority(), + Some(new_key), + vec![], + TOKEN_PROGRAM_ID, + ); + let [def_post] = post_states.try_into().unwrap(); + + let def = TokenDefinition::try_from(&def_post.account().data).unwrap(); + let auth = match def { + TokenDefinition::Fungible { authority, .. } => authority.authority(), + _ => None, + }; + assert_eq!(auth, Some([7_u8; 32])); + } + + #[test] + fn set_authority_revokes_permanently() { + let post_states = set_authority(def_with_authority(), None, vec![], TOKEN_PROGRAM_ID); + let [def_post] = post_states.try_into().unwrap(); + + let def = TokenDefinition::try_from(&def_post.account().data).unwrap(); + let renounced = match def { + TokenDefinition::Fungible { authority, .. } => authority.is_renounced(), + _ => false, + }; + assert!(renounced); + } + + #[test] + #[should_panic(expected = "SetAuthority failed")] + fn set_authority_on_revoked_fails() { + let _ = set_authority( + def_with_authority_revoked(), + Some(AccountId::new([7_u8; 32])), + vec![], + TOKEN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "Mint authority must authorize the transaction")] + fn set_authority_without_is_authorized_fails() { + let mut def = def_with_authority(); + def.is_authorized = false; + let _ = set_authority( + def, + Some(AccountId::new([7_u8; 32])), + vec![], + TOKEN_PROGRAM_ID, + ); + } + + #[test] + #[should_panic(expected = "SetAuthority failed")] + fn set_authority_wrong_signer_fails() { + let _ = set_authority( + def_wrong_authority(), + Some(AccountId::new([7_u8; 32])), + vec![], + TOKEN_PROGRAM_ID, + ); + } + + #[test] + fn set_authority_rotate_then_old_cannot_mint() { + let new_key = AccountId::new([7_u8; 32]); + let post_states = set_authority( + def_with_authority(), + Some(new_key), + vec![], + TOKEN_PROGRAM_ID, + ); + let [def_post] = post_states.try_into().unwrap(); + + let def = TokenDefinition::try_from(&def_post.account().data).unwrap(); + let auth = match def { + TokenDefinition::Fungible { authority, .. } => authority.authority(), + _ => None, + }; + // Rotated to the new key; the old authority no longer controls it. + assert_eq!(auth, Some([7_u8; 32])); + assert_ne!(auth, Some(AUTHORITY)); + } + + /// Authority signer for the rotated key B ([7;32]), authorized. + fn new_authority_signer() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: AccountId::new([7_u8; 32]), + } + } + + /// RFP-001 end-to-end (comment #1): after rotating authority A -> B, the new + /// authority B can actually mint by presenting itself in `authority_accounts`. + #[test] + fn rotated_authority_can_mint() { + // Rotate A ([15;32]) -> B ([7;32]), signed by A via self-authority. + let rotate_post = set_authority( + def_with_authority(), + Some(AccountId::new([7_u8; 32])), + vec![], + TOKEN_PROGRAM_ID, + ); + let [def_post] = rotate_post.try_into().unwrap(); + + // Rebuild the definition carrying the rotated authority, re-authorized. + let mut rotated_def = def_with_authority(); + rotated_def.account = def_post.account().clone(); + + // B mints by presenting itself as the external authority. + let mint_post = mint( + rotated_def, + holding_account(), + 10_000, + vec![new_authority_signer()], + TOKEN_PROGRAM_ID, + ); + let [def_after, holding_after, _auth] = mint_post.try_into().unwrap(); + let minted = TokenDefinition::try_from(&def_after.account().data).unwrap(); + assert!(matches!( + minted, + TokenDefinition::Fungible { + total_supply: 110_000, + .. + } + )); + let holding = TokenHolding::try_from(&holding_after.account().data).unwrap(); + assert!(matches!( + holding, + TokenHolding::Fungible { + balance: 11_000, + .. + } + )); + } + + /// Comment #1 negative: after rotation to B, the OLD authority A can no + /// longer mint. Here A attempts self-authority (empty `authority_accounts`), + /// but the definition's own id no longer matches the stored authority B. + #[test] + #[should_panic(expected = "Mint authority check failed")] + fn rotated_authority_old_key_cannot_mint() { + let rotate_post = set_authority( + def_with_authority(), + Some(AccountId::new([7_u8; 32])), + vec![], + TOKEN_PROGRAM_ID, + ); + let [def_post] = rotate_post.try_into().unwrap(); + + let mut rotated_def = def_with_authority(); + rotated_def.account = def_post.account().clone(); + + // A ([15;32]) is no longer the authority; self-authority must fail. + let _ = mint( + rotated_def, + holding_account(), + 10_000, + vec![], + TOKEN_PROGRAM_ID, + ); + } +} diff --git a/scripts/demo-full-flow.sh b/scripts/demo-full-flow.sh new file mode 100755 index 00000000..d3406f12 --- /dev/null +++ b/scripts/demo-full-flow.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# LP-0013 End-to-End Demo Script +# Demonstrates the full mint authority lifecycle against a real LEZ sequencer. +# +# Prerequisites: +# - lgs (logos-scaffold): https://github.com/logos-blockchain/logos-execution-zone +# - spel CLI: https://github.com/logos-co/spel +# - A funded wallet (run: lgs wallet topup) +# +# Usage (from inside an lgs scaffold project directory): +# cd +# RISC0_DEV_MODE=0 bash /scripts/demo-full-flow.sh +# +# Environment variables (all optional, auto-detected): +# DEMO_DIR / LEZ_PROGRAMS / SPEL / TOKEN_BIN / IDL / WALLET_DIR +# +# The script will: +# 1. Start a local LEZ sequencer +# 2. Fund the wallet +# 3. Create token accounts +# 4. Submit NewFungibleDefinition transaction (with mint authority) +# 5. Submit Mint transaction (authority-gated) +# 6. Submit SetAuthority (revoke) transaction +# 7. Run unit tests to verify authority logic (60 tests) +set -euo pipefail + +if command -v gtimeout &>/dev/null; then + TIMEOUT="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT="timeout" +else + echo "Warning: no timeout command found, running without timeout" + TIMEOUT="" +fi +SPEL="${SPEL:-$HOME/rebase-lez/spel/target/release/spel}" +LEZ_PROGRAMS="${LEZ_PROGRAMS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +IDL="${IDL:-$LEZ_PROGRAMS/artifacts/token-idl.json}" +# Resolve the token guest binary from either build layout, in priority order: +# 1. `cargo risczero build --manifest-path programs/token/methods/guest/Cargo.toml` +# -> programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin +# (the build command documented in the README) +# 2. workspace build (`cargo build` / `cargo test`) +# -> target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin +# An explicit TOKEN_BIN env var always takes precedence. +_risc0_token_bin="$LEZ_PROGRAMS/programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin" +_workspace_token_bin="$LEZ_PROGRAMS/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin" +if [ -z "${TOKEN_BIN:-}" ]; then + if [ -f "$_risc0_token_bin" ]; then + TOKEN_BIN="$_risc0_token_bin" + else + TOKEN_BIN="$_workspace_token_bin" + fi +fi +DEMO_DIR="${DEMO_DIR:-$(pwd)}" +WALLET_DIR="${WALLET_DIR:-$DEMO_DIR/.scaffold/wallet}" + +# Convert a base58 "Public/..." account_id to the 64-char hex form +# that spel expects for [u8; 32] args (e.g. --mint-authority). +b58_to_hex() { + local id="${1#Public/}" + id="${id#Private/}" + python3 -c " +import sys +s = sys.argv[1] +alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +num = 0 +for c in s: + num = num * 58 + alphabet.index(c) +print(num.to_bytes(32, 'big').hex()) +" "$id" +} + +echo "================================================================" +echo " LP-0013: Token Program Mint Authority — End-to-End Demo" +echo " RISC0_DEV_MODE=${RISC0_DEV_MODE:-not set}" +echo "================================================================" +echo "" + +echo "[1/7] Checking localnet..." +cd "$DEMO_DIR" +if lgs localnet status 2>/dev/null | grep -q "ready: true"; then + echo " Localnet already running." +else + lgs localnet start + echo " Localnet started." +fi + +echo "[2/7] Funding wallet..." +lgs wallet topup 2>&1 | grep -E "complete|funded|Address" || true +echo " Wallet funded." + +echo "[3/7] Creating token accounts..." +DEF_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +SUPPLY_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +RECIPIENT_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +echo " Definition account: $DEF_ID" +echo " Supply account: $SUPPLY_ID" +echo " Recipient account: $RECIPIENT_ID" + +echo "[4/7] Creating token with mint authority..." + +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- new-fungible-definition \ + --definition-target-account "$DEF_ID" \ + --holding-target-account "$SUPPLY_ID" \ + --name "DemoCoin" \ + --total-supply 1000000 \ + --mint-authority "$DEF_ID" & +SPEL_PID=$! +sleep 15 && kill $SPEL_PID 2>/dev/null || true +wait $SPEL_PID 2>/dev/null || true +echo " Token 'DemoCoin' submitted. Initial supply: 1,000,000" +sleep 2 + +echo "[5/7] Minting 500,000 additional tokens..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- mint \ + --definition-account "$DEF_ID" \ + --user-holding-account "$RECIPIENT_ID" \ + --amount-to-mint 500000 & +SPEL_PID=$! +sleep 15 && kill $SPEL_PID 2>/dev/null || true +wait $SPEL_PID 2>/dev/null || true +echo " Mint transaction submitted. New total supply: 1,500,000" +sleep 2 + +echo "[6/7] Revoking mint authority..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- set-authority \ + --definition-account "$DEF_ID" \ + --new-authority none & +SPEL_PID=$! +sleep 15 && kill $SPEL_PID 2>/dev/null || true +wait $SPEL_PID 2>/dev/null || true +echo " Authority revoked. Supply permanently fixed at 1,500,000" +sleep 2 + +echo "[7/7] Running unit tests to verify authority logic..." +cd "$LEZ_PROGRAMS" +RISC0_DEV_MODE=0 cargo test -p token_program -p lez-authority --lib 2>&1 | grep -E "test result|authority|ok$" + +echo "" +echo "================================================================" +echo " LP-0013 Demo Complete" +echo " Summary:" +echo " [1/4] NewFungibleDefinition (with authority) → supply=1,000,000" +echo " [2/4] Mint 500,000 → supply=1,500,000" +echo " [3/4] SetAuthority (revoke) → supply fixed" +echo " [4/4] Unit tests passing → all authority cases verified" +echo "================================================================" diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 120000 index 00000000..cd4a4ebf --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1 @@ +demo-full-flow.sh \ No newline at end of file diff --git a/scripts/examples/fixed_supply_token.sh b/scripts/examples/fixed_supply_token.sh new file mode 100755 index 00000000..644ae730 --- /dev/null +++ b/scripts/examples/fixed_supply_token.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# LP-0013 Example: Fixed Supply Token +# +# Demonstrates, against a real LEZ sequencer via spel: +# - creating a fungible token with a mint authority +# - minting additional supply (authority-gated) +# - permanently revoking the mint authority (fixed supply) +# +# After revocation, AuthoritySlot rejects any further mint. That guarantee is +# covered by: +# - token_program unit tests: mint_with_revoked_authority_fails, +# set_authority_on_revoked_fails +# - integration test: token_set_authority_revoke +# Run them with: RISC0_DEV_MODE=0 cargo test -p token_program -p lez-authority --lib +# +# Usage (from inside an lgs scaffold project dir): +# RISC0_DEV_MODE=0 bash /scripts/examples/fixed_supply_token.sh +set -euo pipefail + +SPEL="${SPEL:-$HOME/rebase-lez/spel/target/release/spel}" +LEZ_PROGRAMS="${LEZ_PROGRAMS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +IDL="${IDL:-$LEZ_PROGRAMS/artifacts/token-idl.json}" +TOKEN_BIN="${TOKEN_BIN:-$LEZ_PROGRAMS/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin}" +WALLET_DIR="${WALLET_DIR:-$(pwd)/.scaffold/wallet}" + +b58_to_hex() { + local id="${1#Public/}" + id="${id#Private/}" + python3 -c " +import sys +s = sys.argv[1] +alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +num = 0 +for c in s: + num = num * 58 + alphabet.index(c) +print(num.to_bytes(32, 'big').hex()) +" "$id" +} + +echo "=== Fixed Supply Token Example ===" + +echo "[1/4] Checking localnet..." +lgs localnet status 2>/dev/null | grep -q "ready: true" || lgs localnet start +echo " Localnet ready." + +echo "[2/4] Creating accounts..." +DEF_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +HOLD_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +DEF_ID_HEX=$(b58_to_hex "$DEF_ID") +echo " Definition: $DEF_ID" +echo " Holding: $HOLD_ID" + +echo "[3/4] Creating token with mint authority..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- new-fungible-definition \ + --definition-target-account "$DEF_ID" \ + --holding-target-account "$HOLD_ID" \ + --name "FixedCoin" \ + --total-supply 1000000 \ + --mint-authority "$DEF_ID_HEX" +echo " Token 'FixedCoin' created. Initial supply: 1,000,000" +sleep 2 + +echo "[4/4] Revoking mint authority (fixing supply permanently)..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- set-authority \ + --definition-account "$DEF_ID" \ + --authority-account "$DEF_ID" \ + --new-authority none +echo " Authority revoked. Supply is permanently fixed at 1,000,000." +echo " Any further mint is now rejected — enforced by lez-authority and" +echo " covered by mint_with_revoked_authority_fails / token_set_authority_revoke." + +echo "" +echo "=== Fixed Supply Token Example complete ===" diff --git a/scripts/examples/variable_supply_token.sh b/scripts/examples/variable_supply_token.sh new file mode 100755 index 00000000..35d1ba30 --- /dev/null +++ b/scripts/examples/variable_supply_token.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# LP-0013 Example: Variable Supply Token with Authority Rotation +# +# Demonstrates, against a real LEZ sequencer via spel: +# - creating a fungible token with a mint authority +# - minting additional supply (authority-gated) +# - rotating the mint authority to a new key +# +# The guarantee that the OLD authority can no longer mint after rotation — +# and that only the current authority's signer is accepted — is enforced by +# lez-authority's AuthoritySlot and covered by: +# - token_program unit tests: set_authority_rotate_then_old_cannot_mint, +# mint_with_wrong_signer_fails, set_authority_wrong_signer_fails +# - integration test: token_set_authority_revoke +# Run them with: RISC0_DEV_MODE=0 cargo test -p token_program -p lez-authority --lib +# +# Usage (from inside an lgs scaffold project dir): +# RISC0_DEV_MODE=0 bash /scripts/examples/variable_supply_token.sh +set -euo pipefail + +SPEL="${SPEL:-$HOME/rebase-lez/spel/target/release/spel}" +LEZ_PROGRAMS="${LEZ_PROGRAMS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +IDL="${IDL:-$LEZ_PROGRAMS/artifacts/token-idl.json}" +TOKEN_BIN="${TOKEN_BIN:-$LEZ_PROGRAMS/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin}" +WALLET_DIR="${WALLET_DIR:-$(pwd)/.scaffold/wallet}" + +b58_to_hex() { + local id="${1#Public/}" + id="${id#Private/}" + python3 -c " +import sys +s = sys.argv[1] +alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +num = 0 +for c in s: + num = num * 58 + alphabet.index(c) +print(num.to_bytes(32, 'big').hex()) +" "$id" +} + +echo "=== Variable Supply Token (Authority Rotation) Example ===" + +echo "[1/5] Checking localnet..." +lgs localnet status 2>/dev/null | grep -q "ready: true" || lgs localnet start +echo " Localnet ready." + +echo "[2/5] Creating accounts..." +DEF_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +HOLD_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +NEW_AUTH_ID=$(lgs wallet -- account new public 2>&1 | grep -oE 'account_id [^ ]+' | awk '{print $2}') +DEF_ID_HEX=$(b58_to_hex "$DEF_ID") +NEW_AUTH_HEX=$(b58_to_hex "$NEW_AUTH_ID") +echo " Definition: $DEF_ID" +echo " New authority (rotation target): $NEW_AUTH_ID" + +echo "[3/5] Creating token with mint authority (the definition account)..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- new-fungible-definition \ + --definition-target-account "$DEF_ID" \ + --holding-target-account "$HOLD_ID" \ + --name "VarCoin" \ + --total-supply 100000 \ + --mint-authority "$DEF_ID_HEX" +echo " Token 'VarCoin' created. Initial supply: 100,000" +sleep 2 + +echo "[4/5] Minting 50,000 more (authority-gated)..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- mint \ + --definition-account "$DEF_ID" \ + --authority-account "$DEF_ID" \ + --user-holding-account "$HOLD_ID" \ + --amount-to-mint 50000 +echo " Minted. Total supply: 150,000" +sleep 2 + +echo "[5/5] Rotating mint authority to a new key..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +"$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- set-authority \ + --definition-account "$DEF_ID" \ + --authority-account "$DEF_ID" \ + --new-authority "$NEW_AUTH_HEX" +echo " Authority rotated to: $NEW_AUTH_ID" +echo " After rotation, only the new authority's signer can mint —" +echo " enforced by lez-authority and covered by unit/integration tests." + +echo "" +echo "=== Variable Supply Token Example complete ==="