diff --git a/Cargo.lock b/Cargo.lock index 9372e45..df7e240 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,7 @@ name = "amm_program" version = "0.1.0" dependencies = [ "amm_core", + "lez-authority", "nssa_core", "token_core", ] @@ -1716,6 +1717,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -3257,6 +3266,7 @@ name = "token_core" version = "0.1.0" dependencies = [ "borsh", + "lez-authority", "nssa_core", "serde", "spel-framework-macros", @@ -3266,6 +3276,7 @@ dependencies = [ name = "token_program" version = "0.1.0" dependencies = [ + "lez-authority", "nssa_core", "token_core", ] diff --git a/Cargo.toml b/Cargo.toml index 7d68ac5..90a0743 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 b89de30..ccce4bd 100644 --- a/artifacts/amm-idl.json +++ b/artifacts/amm-idl.json @@ -400,6 +400,12 @@ "type": { "option": "account_id" } + }, + { + "name": "authority", + "type": { + "defined": "Authority" + } } ] }, diff --git a/artifacts/ata-idl.json b/artifacts/ata-idl.json index 11e99cb..5037b0a 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 5c601e8..e492f49 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 73c5771..dec354c 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" + } } ] }, @@ -153,6 +159,25 @@ } ] }, + { + "name": "set_authority", + "accounts": [ + { + "name": "definition_account", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "new_authority", + "type": { + "option": "account_id" + } + } + ] + }, { "name": "print_nft", "accounts": [ @@ -194,6 +219,12 @@ "type": { "option": "account_id" } + }, + { + "name": "authority", + "type": { + "defined": "Authority" + } } ] }, diff --git a/docs/LP-0013-README.md b/docs/LP-0013-README.md new file mode 100644 index 0000000..b11a848 --- /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 sign 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 0000000..7cef526 --- /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 0000000..4efa7c5 --- /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 28dce03..cdb95d6 100644 --- a/programs/amm/Cargo.toml +++ b/programs/amm/Cargo.toml @@ -7,6 +7,9 @@ edition = "2021" workspace = true [dependencies] -nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = [ + "host", +] } amm_core = { path = "core" } token_core = { path = "../token/core" } +lez-authority = { path = "../../lez-authority" } diff --git a/programs/amm/methods/guest/Cargo.lock b/programs/amm/methods/guest/Cargo.lock index 7dcd666..88ec2ef 100644 --- a/programs/amm/methods/guest/Cargo.lock +++ b/programs/amm/methods/guest/Cargo.lock @@ -60,6 +60,7 @@ name = "amm_program" version = "0.1.0" dependencies = [ "amm_core", + "lez-authority", "nssa_core", "token_core", ] @@ -1659,6 +1660,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -3150,6 +3159,7 @@ name = "token_core" version = "0.1.0" dependencies = [ "borsh", + "lez-authority", "nssa_core", "serde", "spel-framework-macros", diff --git a/programs/amm/src/new_definition.rs b/programs/amm/src/new_definition.rs index 8c47a6a..8eb7704 100644 --- a/programs/amm/src/new_definition.rs +++ b/programs/amm/src/new_definition.rs @@ -6,6 +6,7 @@ use amm_core::{ compute_pool_pda_seed, compute_vault_pda, compute_vault_pda_seed, PoolDefinition, MINIMUM_LIQUIDITY, }; +use lez_authority::Authority; use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, @@ -167,6 +168,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![ @@ -180,8 +182,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 bb1831e..0a561b4 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -536,10 +536,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![ @@ -789,6 +790,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), }, @@ -814,6 +821,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), }, @@ -831,6 +844,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), }, @@ -2816,6 +2835,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![ @@ -2879,6 +2899,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 ba53cf4..e9a28f3 100644 --- a/programs/ata/methods/guest/Cargo.lock +++ b/programs/ata/methods/guest/Cargo.lock @@ -1657,6 +1657,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -3148,6 +3156,7 @@ name = "token_core" version = "0.1.0" dependencies = [ "borsh", + "lez-authority", "nssa_core", "serde", "spel-framework-macros", diff --git a/programs/ata/src/tests.rs b/programs/ata/src/tests.rs index 595cfdd..25de01c 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 bfb6feb..42a465a 100644 --- a/programs/integration_tests/tests/amm.rs +++ b/programs/integration_tests/tests/amm.rs @@ -334,6 +334,7 @@ impl Accounts { name: String::from("test"), total_supply: Balances::token_a_supply(), metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -347,6 +348,7 @@ impl Accounts { name: String::from("test"), total_supply: Balances::token_b_supply(), metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -360,6 +362,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), } @@ -636,6 +644,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), } @@ -728,6 +742,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), } @@ -741,6 +761,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), } @@ -845,6 +871,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), } @@ -1177,6 +1209,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 d88bd3b..40dd5ce 100644 --- a/programs/integration_tests/tests/ata.rs +++ b/programs/integration_tests/tests/ata.rs @@ -85,6 +85,7 @@ impl Accounts { name: String::from("Gold"), total_supply: 1_000_000_u128, metadata_id: None, + authority: token_core::Authority::renounced(), }), nonce: Nonce(0), } @@ -122,6 +123,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), } @@ -496,6 +498,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 4044d84..124b537 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 9e308a6..78afc50 100644 --- a/programs/integration_tests/tests/token.rs +++ b/programs/integration_tests/tests/token.rs @@ -29,6 +29,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 { @@ -51,6 +55,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 { @@ -62,6 +70,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), } @@ -75,6 +89,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), } @@ -103,6 +123,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) { @@ -119,6 +148,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 } @@ -127,6 +157,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 } @@ -138,6 +169,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( @@ -165,6 +197,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), } @@ -416,6 +449,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), } @@ -465,6 +504,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), } @@ -586,6 +631,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), } @@ -904,3 +955,106 @@ fn token_deshielded_transfer() { .get_proof_for_commitment(&Commitment::new(&sender_npk, &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), + } + ); +} diff --git a/programs/stablecoin/methods/guest/Cargo.lock b/programs/stablecoin/methods/guest/Cargo.lock index a382086..8720e25 100644 --- a/programs/stablecoin/methods/guest/Cargo.lock +++ b/programs/stablecoin/methods/guest/Cargo.lock @@ -1624,6 +1624,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -3151,6 +3159,7 @@ name = "token_core" version = "0.1.0" dependencies = [ "borsh", + "lez-authority", "nssa_core", "serde", "spel-framework-macros", diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 41e0154..8f4ac0c 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 ac644ff..5b98d7b 100644 --- a/programs/token/Cargo.toml +++ b/programs/token/Cargo.toml @@ -7,5 +7,8 @@ edition = "2021" workspace = true [dependencies] -nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = [ + "host", +] } token_core = { path = "core" } +lez-authority = { path = "../../lez-authority" } diff --git a/programs/token/core/Cargo.toml b/programs/token/core/Cargo.toml index d5d5f3d..ca899e4 100644 --- a/programs/token/core/Cargo.toml +++ b/programs/token/core/Cargo.toml @@ -7,7 +7,10 @@ edition = "2021" workspace = true [dependencies] -nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = ["host"] } +nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc3", features = [ + "host", +] } spel-framework-macros = { git = "https://github.com/logos-co/spel.git", tag = "v0.3.0", 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 3954537..2236ddd 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)] @@ -84,6 +104,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 +115,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 07f88f1..9846a72 100644 --- a/programs/token/methods/guest/Cargo.lock +++ b/programs/token/methods/guest/Cargo.lock @@ -1624,6 +1624,14 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lez-authority" +version = "0.1.0" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "libc" version = "0.2.186" @@ -3128,6 +3136,7 @@ name = "token_core" version = "0.1.0" dependencies = [ "borsh", + "lez-authority", "nssa_core", "serde", "spel-framework-macros", @@ -3137,6 +3146,7 @@ dependencies = [ name = "token_program" version = "0.1.0" dependencies = [ + "lez-authority", "nssa_core", "token_core", ] diff --git a/programs/token/methods/guest/src/bin/token.rs b/programs/token/methods/guest/src/bin/token.rs index e3955fc..d1fb2ec 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); @@ -23,21 +23,22 @@ 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( definition_target_account: AccountWithMetadata, 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( @@ -45,6 +46,7 @@ mod token { holding_target_account, name, total_supply, + mint_authority, ), vec![], )) @@ -101,14 +103,14 @@ 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 definition account must be authorized as the current mint authority. /// Fresh public holders must be explicitly authorized in the same transaction. #[instruction] pub fn mint( @@ -117,12 +119,29 @@ mod token { user_holding_account: AccountWithMetadata, 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, + 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 definition account must be authorized as the current mint authority. + #[instruction] + pub fn set_authority( + definition_account: AccountWithMetadata, + new_authority: Option, + ) -> SpelResult { + Ok(spel_framework::SpelOutput::execute( + token_program::set_authority::set_authority(definition_account, new_authority), + vec![], + )) } /// Print a new NFT from the master copy. @@ -132,9 +151,9 @@ mod token { master_account: AccountWithMetadata, 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 94637d9..e984745 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 8b0698c..b0d1361 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 0c638d1..d2fdc42 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}, @@ -10,10 +11,6 @@ pub fn mint( amount_to_mint: u128, 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 +18,27 @@ 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 mint authority: the definition account + // must be authorized in this transaction and its id must match the stored + // authority. This holds for an external owner that signs the definition key, + // and for a program-controlled PDA authorized via its seeds (e.g. the AMM's + // pool definition minting LP tokens). + if let TokenDefinition::Fungible { .. } = &definition { + assert!( + definition_account.is_authorized, + "Mint authority must authorize the transaction" + ); + let signer: [u8; 32] = definition_account + .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 +58,7 @@ pub fn mint( name: _, metadata_id: _, total_supply, + authority: _, }, TokenHolding::Fungible { definition_id: _, diff --git a/programs/token/src/new_definition.rs b/programs/token/src/new_definition.rs index 91967a0..32bad97 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, @@ -97,6 +121,7 @@ pub fn new_definition_with_metadata( name, total_supply, metadata_id: Some(metadata_target_account.account_id), + authority: Authority::renounced(), }, TokenHolding::Fungible { definition_id: definition_target_account.account_id, @@ -124,7 +149,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 0000000..91b4b1b --- /dev/null +++ b/programs/token/src/set_authority.rs @@ -0,0 +1,60 @@ +use lez_authority::Ownable; +use nssa_core::{ + account::{AccountId, AccountWithMetadata, Data}, + program::AccountPostState, +}; +use token_core::TokenDefinition; + +pub fn set_authority( + definition_account: AccountWithMetadata, + new_authority: Option, +) -> Vec { + 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: the + // definition account must be authorized and its id must match the + // stored authority. + assert!( + definition_account.is_authorized, + "Mint authority must authorize the transaction" + ); + let signer: [u8; 32] = definition_account + .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); + + vec![AccountPostState::new(definition_post)] +} diff --git a/programs/token/src/tests.rs b/programs/token/src/tests.rs index 2df8e5c..502aa2b 100644 --- a/programs/token/src/tests.rs +++ b/programs/token/src/tests.rs @@ -42,6 +42,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 +60,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 +78,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 +160,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 +242,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 +333,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 +600,7 @@ fn test_new_definition_non_default_first_account_should_fail() { holding_account, String::from("test"), 10, + None, ); } @@ -618,6 +625,7 @@ fn test_new_definition_non_default_second_account_should_fail() { holding_account, String::from("test"), 10, + None, ); } @@ -631,6 +639,7 @@ fn test_new_definition_requires_authorized_definition_target() { holding_account, String::from("test"), 10, + None, ); } @@ -644,6 +653,7 @@ fn test_new_definition_requires_authorized_holding_target() { holding_account, String::from("test"), 10, + None, ); } @@ -657,6 +667,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(); @@ -918,9 +929,11 @@ fn test_mint_not_valid_definition_account() { } #[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, @@ -946,6 +959,7 @@ 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( @@ -1313,3 +1327,219 @@ 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, + 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, + 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, 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, + 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]))); + } + + #[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)); + 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); + 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])), + ); + } + + #[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]))); + } + + #[test] + #[should_panic(expected = "SetAuthority failed")] + fn set_authority_wrong_signer_fails() { + let _ = set_authority(def_wrong_authority(), Some(AccountId::new([7_u8; 32]))); + } + + #[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)); + 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)); + } +} diff --git a/scripts/demo-full-flow.sh b/scripts/demo-full-flow.sh new file mode 100755 index 0000000..ef4e38c --- /dev/null +++ b/scripts/demo-full-flow.sh @@ -0,0 +1,131 @@ +#!/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 NewFungibleDefinitionWithAuthority transaction +# 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}" +TOKEN_BIN="${TOKEN_BIN:-$LEZ_PROGRAMS/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin}" +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..." +DEF_ID_HEX=$(b58_to_hex "$DEF_ID") +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +${TIMEOUT:+$TIMEOUT 30} "$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- new-fungible-definition-with-authority \ + --definition-target-account "$DEF_ID" \ + --holding-target-account "$SUPPLY_ID" \ + --name "DemoCoin" \ + --initial-supply 1000000 \ + --mint-authority "$DEF_ID_HEX" +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" \ +${TIMEOUT:+$TIMEOUT 30} "$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- mint \ + --definition-account "$DEF_ID" \ + --authority-account "$DEF_ID" \ + --user-holding-account "$RECIPIENT_ID" \ + --amount-to-mint 500000 +echo " Mint transaction submitted. New total supply: 1,500,000" +sleep 2 + +echo "[6/7] Revoking mint authority..." +NSSA_WALLET_HOME_DIR="$WALLET_DIR" \ +${TIMEOUT:+$TIMEOUT 30} "$SPEL" --idl "$IDL" --program "$TOKEN_BIN" \ + -- set-authority \ + --definition-account "$DEF_ID" \ + --authority-account "$DEF_ID" \ + --new-authority none +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] NewFungibleDefinitionWithAuthority → 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/examples/fixed_supply_token.sh b/scripts/examples/fixed_supply_token.sh new file mode 100755 index 0000000..0537436 --- /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-with-authority \ + --definition-target-account "$DEF_ID" \ + --holding-target-account "$HOLD_ID" \ + --name "FixedCoin" \ + --initial-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 0000000..9c58dd0 --- /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-with-authority \ + --definition-target-account "$DEF_ID" \ + --holding-target-account "$HOLD_ID" \ + --name "VarCoin" \ + --initial-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 ==="