diff --git a/packages/svm/Anchor.toml b/packages/svm/Anchor.toml index 1bb4fa9..e1ff45d 100644 --- a/packages/svm/Anchor.toml +++ b/packages/svm/Anchor.toml @@ -7,6 +7,7 @@ skip-lint = false [programs.localnet] settler = "HbNt35Ng8aM4NUy39evpCQqXEC4Nmaq16ewY8dyNF6NF" +controller = "7PwVkjnnapxytWFW69WFDLhfVZZgKhBE9m3zwcDZmncr" [registry] url = "https://api.apr.dev" @@ -16,4 +17,4 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "yarn run ts-mocha -p ./tsconfig.json tests/**/*.ts" +test = "yarn run ts-mocha -p ./tsconfig.json tests/*.ts" diff --git a/packages/svm/Cargo.lock b/packages/svm/Cargo.lock index 66d20c5..3c06e6d 100644 --- a/packages/svm/Cargo.lock +++ b/packages/svm/Cargo.lock @@ -404,6 +404,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "controller" +version = "0.1.0" +dependencies = [ + "anchor-lang", +] + [[package]] name = "cpufeatures" version = "0.2.17" diff --git a/packages/svm/idls/controller.json b/packages/svm/idls/controller.json new file mode 100644 index 0000000..71f8265 --- /dev/null +++ b/packages/svm/idls/controller.json @@ -0,0 +1,518 @@ +{ + "address": "7PwVkjnnapxytWFW69WFDLhfVZZgKhBE9m3zwcDZmncr", + "metadata": { + "name": "controller", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "initialize", + "discriminator": [ + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 + ], + "accounts": [ + { + "name": "deployer", + "writable": true, + "signer": true + }, + { + "name": "global_settings", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 45, + 115, + 101, + 116, + 116, + 105, + 110, + 103, + 115 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "proposed_admin_cooldown", + "type": "u64" + } + ] + }, + { + "name": "propose_admin", + "discriminator": [ + 121, + 214, + 199, + 212, + 87, + 39, + 117, + 234 + ], + "accounts": [ + { + "name": "admin", + "writable": true, + "signer": true, + "relations": [ + "global_settings" + ] + }, + { + "name": "global_settings", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 45, + 115, + 101, + 116, + 116, + 105, + 110, + 103, + 115 + ] + } + ] + } + } + ], + "args": [ + { + "name": "proposed_admin", + "type": "pubkey" + } + ] + }, + { + "name": "set_entity_allowlist_status", + "discriminator": [ + 100, + 20, + 23, + 73, + 220, + 118, + 179, + 50 + ], + "accounts": [ + { + "name": "admin", + "writable": true, + "signer": true, + "relations": [ + "global_settings" + ] + }, + { + "name": "entity_registry", + "writable": true + }, + { + "name": "global_settings", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 45, + 115, + 101, + 116, + 116, + 105, + 110, + 103, + 115 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "entity_type", + "type": { + "defined": { + "name": "EntityType" + } + } + }, + { + "name": "entity_pubkey", + "type": "pubkey" + }, + { + "name": "status", + "type": { + "defined": { + "name": "AllowlistStatus" + } + } + } + ] + }, + { + "name": "set_proposed_admin", + "discriminator": [ + 160, + 170, + 199, + 240, + 246, + 244, + 199, + 2 + ], + "accounts": [ + { + "name": "proposed_admin", + "writable": true, + "signer": true + }, + { + "name": "global_settings", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108, + 45, + 115, + 101, + 116, + 116, + 105, + 110, + 103, + 115 + ] + } + ] + } + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "EntityRegistry", + "discriminator": [ + 206, + 167, + 4, + 107, + 132, + 162, + 158, + 163 + ] + }, + { + "name": "GlobalSettings", + "discriminator": [ + 109, + 67, + 50, + 55, + 2, + 20, + 148, + 62 + ] + } + ], + "events": [ + { + "name": "SetEntityAllowlistStatusEvent", + "discriminator": [ + 137, + 194, + 109, + 101, + 80, + 30, + 4, + 114 + ] + }, + { + "name": "SetProposedAdminEvent", + "discriminator": [ + 153, + 83, + 248, + 103, + 132, + 126, + 171, + 96 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "OnlyDeployer", + "msg": "Only deployer can call this instruction" + }, + { + "code": 6001, + "name": "OnlyAdmin", + "msg": "Only admin can call this instruction" + }, + { + "code": 6002, + "name": "OnlyProposedAdmin", + "msg": "Only proposed admin can call this instruction" + }, + { + "code": 6003, + "name": "ProposedAdminIsAlreadySet", + "msg": "Proposed admin is already set" + }, + { + "code": 6004, + "name": "SetProposedAdminError", + "msg": "Can't set proposed admin - either no next admin is proposed or cooldown period is not over yet" + }, + { + "code": 6005, + "name": "CooldownTooLarge", + "msg": "Cooldown too large" + }, + { + "code": 6006, + "name": "CooldownCantBeZero", + "msg": "Cooldown can't be zero" + }, + { + "code": 6007, + "name": "MathError", + "msg": "Math error" + } + ], + "types": [ + { + "name": "EntityRegistry", + "type": { + "kind": "struct", + "fields": [ + { + "name": "entity_type", + "type": { + "defined": { + "name": "EntityType" + } + } + }, + { + "name": "entity_pubkey", + "type": "pubkey" + }, + { + "name": "status", + "type": { + "defined": { + "name": "AllowlistStatus" + } + } + }, + { + "name": "last_update", + "type": "u64" + }, + { + "name": "updated_by", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "EntityType", + "repr": { + "kind": "rust" + }, + "type": { + "kind": "enum", + "variants": [ + { + "name": "Validator" + }, + { + "name": "Axia" + }, + { + "name": "Solver" + } + ] + } + }, + { + "name": "GlobalSettings", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "proposed_admin", + "type": { + "option": "pubkey" + } + }, + { + "name": "proposed_admin_cooldown", + "type": "u64" + }, + { + "name": "proposed_admin_next_change_timestamp", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "SetEntityAllowlistStatusEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "entity_type", + "type": { + "defined": { + "name": "EntityType" + } + } + }, + { + "name": "entity_pubkey", + "type": "pubkey" + }, + { + "name": "status", + "type": { + "defined": { + "name": "AllowlistStatus" + } + } + }, + { + "name": "timestamp", + "type": "u64" + }, + { + "name": "updated_by", + "type": "pubkey" + } + ] + } + }, + { + "name": "SetProposedAdminEvent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "old_admin", + "type": "pubkey" + }, + { + "name": "new_admin", + "type": "pubkey" + } + ] + } + }, + { + "name": "AllowlistStatus", + "repr": { + "kind": "rust" + }, + "type": { + "kind": "enum", + "variants": [ + { + "name": "Allowlisted" + }, + { + "name": "Blacklisted" + } + ] + } + } + ] +} diff --git a/packages/svm/package.json b/packages/svm/package.json index bf28823..9aeca27 100644 --- a/packages/svm/package.json +++ b/packages/svm/package.json @@ -5,13 +5,14 @@ "license": "GPL-3.0", "type": "module", "scripts": { - "build": "anchor build", - "test": "anchor test", + "build": "DEPLOYER_KEY=$(solana-keygen pubkey) anchor build", + "test": "anchor run test", "lint": "eslint . --ext .ts", "lint:fix": "yarn lint --fix" }, "dependencies": { "@coral-xyz/anchor": "^0.32.1", + "@noble/ed25519": "^3.0.0", "@solana/spl-token": "^0.4.13", "anchor-litesvm": "=0.1.0", "litesvm": "=0.1.0" @@ -22,12 +23,12 @@ "@types/mocha": "^10.0.10", "@types/node": "^22.15.18", "chai": "^5.2.0", + "eslint": "^7.9.0", + "eslint-config-mimic": "^0.0.2", "mocha": "^11.2.2", "prettier": "^2.6.2", "ts-mocha": "^10.0.0", - "typescript": "~5.5.0", - "eslint": "^7.9.0", - "eslint-config-mimic": "^0.0.2" + "typescript": "~5.5.0" }, "eslintConfig": { "extends": "eslint-config-mimic" diff --git a/packages/svm/programs/controller/Cargo.toml b/packages/svm/programs/controller/Cargo.toml new file mode 100644 index 0000000..29e05b6 --- /dev/null +++ b/packages/svm/programs/controller/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "controller" +version = "0.1.0" +description = "Manages allowlist for Mimic Protocol entities" +edition = "2021" +license = "GPL-3.0" + +[lib] +crate-type = ["cdylib", "lib"] +name = "controller" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = { version = "0.32.1", features = [ "init-if-needed" ] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/packages/svm/programs/controller/src/constants.rs b/packages/svm/programs/controller/src/constants.rs new file mode 100644 index 0000000..c2edf8e --- /dev/null +++ b/packages/svm/programs/controller/src/constants.rs @@ -0,0 +1,4 @@ +pub const DEPLOYER_KEY: &str = env!( + "DEPLOYER_KEY", + "Please set the DEPLOYER_KEY env variable before compiling." +); diff --git a/packages/svm/programs/controller/src/errors.rs b/packages/svm/programs/controller/src/errors.rs new file mode 100644 index 0000000..3c6c177 --- /dev/null +++ b/packages/svm/programs/controller/src/errors.rs @@ -0,0 +1,10 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum ControllerError { + #[msg("Only deployer can call this instruction")] + OnlyDeployer, + + #[msg("Only admin can call this instruction")] + OnlyAdmin, +} diff --git a/packages/svm/programs/controller/src/instructions/close_entity_registry.rs b/packages/svm/programs/controller/src/instructions/close_entity_registry.rs new file mode 100644 index 0000000..91390bf --- /dev/null +++ b/packages/svm/programs/controller/src/instructions/close_entity_registry.rs @@ -0,0 +1,52 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::ControllerError, + state::{EntityRegistry, GlobalSettings}, + types::EntityType, +}; + +#[derive(Accounts)] +#[instruction(entity_type: EntityType, entity_pubkey: Pubkey)] +pub struct CloseEntityRegistry<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [b"entity-registry".as_ref(), &[entity_type as u8], entity_pubkey.as_ref()], + bump = entity_registry.bump, + close = admin, + )] + pub entity_registry: Box>, + + #[account( + seeds = [b"global-settings"], + bump = global_settings.bump, + has_one = admin @ ControllerError::OnlyAdmin + )] + pub global_settings: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn close_entity_registry( + _ctx: Context, + entity_type: EntityType, + entity_pubkey: Pubkey, +) -> Result<()> { + emit!(CloseEntityRegistryEvent { + entity_type, + entity_pubkey, + timestamp: Clock::get()?.unix_timestamp as u64, + }); + + Ok(()) +} + +#[event] +pub struct CloseEntityRegistryEvent { + pub entity_type: EntityType, + pub entity_pubkey: Pubkey, + pub timestamp: u64, +} diff --git a/packages/svm/programs/controller/src/instructions/initialize.rs b/packages/svm/programs/controller/src/instructions/initialize.rs new file mode 100644 index 0000000..094cd0a --- /dev/null +++ b/packages/svm/programs/controller/src/instructions/initialize.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; +use std::str::FromStr; + +use crate::{constants::DEPLOYER_KEY, errors::ControllerError, state::GlobalSettings}; + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(mut)] + pub deployer: Signer<'info>, + + #[account( + init, + seeds = [b"global-settings"], + bump, + payer = deployer, + space = 8 + GlobalSettings::INIT_SPACE + )] + pub global_settings: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn initialize(ctx: Context, admin: Pubkey) -> Result<()> { + require_keys_eq!( + ctx.accounts.deployer.key(), + Pubkey::from_str(DEPLOYER_KEY).unwrap(), + ControllerError::OnlyDeployer, + ); + + let global_settings = &mut ctx.accounts.global_settings; + + global_settings.admin = admin; + global_settings.bump = ctx.bumps.global_settings; + + Ok(()) +} diff --git a/packages/svm/programs/controller/src/instructions/mod.rs b/packages/svm/programs/controller/src/instructions/mod.rs new file mode 100644 index 0000000..3028dc9 --- /dev/null +++ b/packages/svm/programs/controller/src/instructions/mod.rs @@ -0,0 +1,9 @@ +pub mod close_entity_registry; +pub mod initialize; +pub mod set_admin; +pub mod set_allowed_entity; + +pub use close_entity_registry::*; +pub use initialize::*; +pub use set_admin::*; +pub use set_allowed_entity::*; diff --git a/packages/svm/programs/controller/src/instructions/set_admin.rs b/packages/svm/programs/controller/src/instructions/set_admin.rs new file mode 100644 index 0000000..dc59043 --- /dev/null +++ b/packages/svm/programs/controller/src/instructions/set_admin.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; + +use crate::{errors::ControllerError, state::GlobalSettings}; + +#[derive(Accounts)] +pub struct SetAdmin<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + mut, + seeds = [b"global-settings"], + bump = global_settings.bump, + has_one = admin @ ControllerError::OnlyAdmin + )] + pub global_settings: Box>, +} + +pub fn set_admin(ctx: Context, new_admin: Pubkey) -> Result<()> { + let global_settings = &mut ctx.accounts.global_settings; + + global_settings.admin = new_admin; + + emit!(SetAdminEvent { + new_admin, + timestamp: Clock::get()?.unix_timestamp as u64, + }); + + Ok(()) +} + +#[event] +pub struct SetAdminEvent { + pub new_admin: Pubkey, + pub timestamp: u64, +} diff --git a/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs b/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs new file mode 100644 index 0000000..f264eea --- /dev/null +++ b/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::ControllerError, + state::{EntityRegistry, GlobalSettings}, + types::EntityType, +}; + +#[derive(Accounts)] +#[instruction(entity_type: EntityType, entity_pubkey: Pubkey)] +pub struct SetAllowedEntity<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + init, + seeds = [b"entity-registry".as_ref(), &[entity_type as u8], entity_pubkey.as_ref()], + bump, + payer = admin, + space = 8 + EntityRegistry::INIT_SPACE + )] + pub entity_registry: Box>, + + #[account( + seeds = [b"global-settings"], + bump = global_settings.bump, + has_one = admin @ ControllerError::OnlyAdmin + )] + pub global_settings: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn set_allowed_entity( + ctx: Context, + entity_type: EntityType, + entity_pubkey: Pubkey, +) -> Result<()> { + let entity_registry = &mut ctx.accounts.entity_registry; + + entity_registry.entity_type = entity_type; + entity_registry.entity_pubkey = entity_pubkey; + entity_registry.bump = ctx.bumps.entity_registry; + + Ok(()) +} diff --git a/packages/svm/programs/controller/src/lib.rs b/packages/svm/programs/controller/src/lib.rs new file mode 100644 index 0000000..a52a231 --- /dev/null +++ b/packages/svm/programs/controller/src/lib.rs @@ -0,0 +1,40 @@ +use anchor_lang::prelude::*; + +declare_id!("7PwVkjnnapxytWFW69WFDLhfVZZgKhBE9m3zwcDZmncr"); + +pub mod constants; +pub mod errors; +pub mod instructions; +pub mod state; +pub mod types; + +use crate::{instructions::*, types::*}; + +#[program] +pub mod controller { + use super::*; + + pub fn initialize(ctx: Context, admin: Pubkey) -> Result<()> { + instructions::initialize(ctx, admin) + } + + pub fn set_admin(ctx: Context, new_admin: Pubkey) -> Result<()> { + instructions::set_admin(ctx, new_admin) + } + + pub fn set_allowed_entity( + ctx: Context, + entity_type: EntityType, + entity_pubkey: Pubkey, + ) -> Result<()> { + instructions::set_allowed_entity(ctx, entity_type, entity_pubkey) + } + + pub fn close_entity_registry( + ctx: Context, + entity_type: EntityType, + entity_pubkey: Pubkey, + ) -> Result<()> { + instructions::close_entity_registry(ctx, entity_type, entity_pubkey) + } +} diff --git a/packages/svm/programs/controller/src/state/entity_registry.rs b/packages/svm/programs/controller/src/state/entity_registry.rs new file mode 100644 index 0000000..dbf917c --- /dev/null +++ b/packages/svm/programs/controller/src/state/entity_registry.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +use crate::types::EntityType; + +#[account] +#[derive(InitSpace)] +pub struct EntityRegistry { + pub entity_type: EntityType, + pub entity_pubkey: Pubkey, + pub bump: u8, +} diff --git a/packages/svm/programs/controller/src/state/global_settings.rs b/packages/svm/programs/controller/src/state/global_settings.rs new file mode 100644 index 0000000..6027636 --- /dev/null +++ b/packages/svm/programs/controller/src/state/global_settings.rs @@ -0,0 +1,8 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct GlobalSettings { + pub admin: Pubkey, + pub bump: u8, +} diff --git a/packages/svm/programs/controller/src/state/mod.rs b/packages/svm/programs/controller/src/state/mod.rs new file mode 100644 index 0000000..e0bef0c --- /dev/null +++ b/packages/svm/programs/controller/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod entity_registry; +pub mod global_settings; + +pub use entity_registry::*; +pub use global_settings::*; diff --git a/packages/svm/programs/controller/src/types/entity_type.rs b/packages/svm/programs/controller/src/types/entity_type.rs new file mode 100644 index 0000000..1d798cc --- /dev/null +++ b/packages/svm/programs/controller/src/types/entity_type.rs @@ -0,0 +1,13 @@ +use anchor_lang::prelude::*; + +#[repr(u8)] +#[derive(Copy, Clone, AnchorSerialize, AnchorDeserialize)] +pub enum EntityType { + Validator = 1, + Axia = 2, + Solver = 3, +} + +impl anchor_lang::Space for EntityType { + const INIT_SPACE: usize = 1; +} diff --git a/packages/svm/programs/controller/src/types/mod.rs b/packages/svm/programs/controller/src/types/mod.rs new file mode 100644 index 0000000..5e95bd3 --- /dev/null +++ b/packages/svm/programs/controller/src/types/mod.rs @@ -0,0 +1,3 @@ +pub mod entity_type; + +pub use entity_type::*; diff --git a/packages/svm/rust-toolchain.toml b/packages/svm/rust-toolchain.toml new file mode 100644 index 0000000..cb684c0 --- /dev/null +++ b/packages/svm/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.89.0" +components = ["rustfmt","clippy"] +profile = "minimal" diff --git a/packages/svm/sdks/controller/Controller.ts b/packages/svm/sdks/controller/Controller.ts new file mode 100644 index 0000000..e2e7b72 --- /dev/null +++ b/packages/svm/sdks/controller/Controller.ts @@ -0,0 +1,99 @@ +import { IdlTypes, Program, Provider, web3 } from '@coral-xyz/anchor' + +import * as ControllerIDL from '../../target/idl/controller.json' +import { Controller } from '../../target/types/controller' + +export const EntityType = { + Validator: 1, + Axia: 2, + Solver: 3, +} as const + +export type EntityType = (typeof EntityType)[keyof typeof EntityType] + +export default class ControllerSDK { + protected program: Program + + constructor(provider: Provider) { + this.program = new Program(ControllerIDL, provider) + } + + async initializeIx(admin: web3.PublicKey): Promise { + const globalSettings = this.getGlobalSettingsPubkey() + const ix = await this.program.methods + .initialize(admin) + .accountsPartial({ + deployer: this.getSignerKey(), + globalSettings, + }) + .instruction() + return ix + } + + async setAdmin(newAdmin: web3.PublicKey): Promise { + const globalSettings = this.getGlobalSettingsPubkey() + const ix = await this.program.methods + .setAdmin(newAdmin) + .accountsPartial({ + admin: this.getSignerKey(), + globalSettings, + }) + .instruction() + return ix + } + + async setAllowedEntityIx(entityType: EntityType, entityPubkey: web3.PublicKey): Promise { + const entityRegistry = this.getEntityRegistryPubkey(entityType, entityPubkey) + const globalSettings = this.getGlobalSettingsPubkey() + const ix = await this.program.methods + .setAllowedEntity(this.entityTypeToAnchorEnum(entityType), entityPubkey) + .accountsPartial({ + admin: this.getSignerKey(), + entityRegistry, + globalSettings, + }) + .instruction() + return ix + } + + async closeEntityRegistryIx( + entityType: EntityType, + entityPubkey: web3.PublicKey + ): Promise { + const entityRegistry = this.getEntityRegistryPubkey(entityType, entityPubkey) + const globalSettings = this.getGlobalSettingsPubkey() + const ix = await this.program.methods + .closeEntityRegistry(this.entityTypeToAnchorEnum(entityType), entityPubkey) + .accountsPartial({ + admin: this.getSignerKey(), + entityRegistry, + globalSettings, + }) + .instruction() + return ix + } + + getSignerKey(): web3.PublicKey { + if (!this.program.provider.wallet) throw new Error('Must set program provider wallet') + return this.program.provider.wallet?.publicKey + } + + getGlobalSettingsPubkey(): web3.PublicKey { + return web3.PublicKey.findProgramAddressSync([Buffer.from('global-settings')], this.program.programId)[0] + } + + getEntityRegistryPubkey(entityType: EntityType, entityPubkey: web3.PublicKey): web3.PublicKey { + return web3.PublicKey.findProgramAddressSync( + [Buffer.from('entity-registry'), Buffer.from([entityType]), entityPubkey.toBuffer()], + this.program.programId + )[0] + } + + entityTypeToAnchorEnum(entityType: EntityType): IdlTypes['entityType'] { + if (entityType === EntityType.Validator) return { validator: {} } + if (entityType === EntityType.Axia) return { axia: {} } + if (entityType === EntityType.Solver) return { solver: {} } + + throw new Error(`Unsupported entity type ${entityType}`) + } +} diff --git a/packages/svm/tests/controller.test.ts b/packages/svm/tests/controller.test.ts new file mode 100644 index 0000000..bf070a1 --- /dev/null +++ b/packages/svm/tests/controller.test.ts @@ -0,0 +1,334 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import { Program, Wallet, web3 } from '@coral-xyz/anchor' +import { fromWorkspace, LiteSVMProvider } from 'anchor-litesvm' +import { expect } from 'chai' +import fs from 'fs' +import { LiteSVM } from 'litesvm' +import os from 'os' +import path from 'path' + +import ControllerSDK, { EntityType } from '../sdks/controller/Controller' +import * as ControllerIDL from '../target/idl/controller.json' +import { Controller } from '../target/types/controller' +import { expectTransactionError, randomKeypair, randomPubkey, toLamports } from './helpers/helpers' +import { makeTxSignAndSend, warpSeconds } from './utils' + +describe('Controller', () => { + let client: LiteSVM + + let deployer: web3.Keypair + let admin: web3.Keypair + let otherAdmin: web3.Keypair + let malicious: web3.Keypair + + let deployerProvider: LiteSVMProvider + let adminProvider: LiteSVMProvider + let otherAdminProvider: LiteSVMProvider + let maliciousProvider: LiteSVMProvider + + let program: Program + + let deployerSdk: ControllerSDK + let adminSdk: ControllerSDK + let otherAdminSdk: ControllerSDK + let maliciousSdk: ControllerSDK + + before(async () => { + deployer = web3.Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config', 'solana', 'id.json'), 'utf8'))) + ) + admin = randomKeypair() + otherAdmin = randomKeypair() + malicious = randomKeypair() + + client = fromWorkspace(path.join(__dirname, '../')).withBuiltins() + + deployerProvider = new LiteSVMProvider(client, new Wallet(deployer)) + adminProvider = new LiteSVMProvider(client, new Wallet(admin)) + otherAdminProvider = new LiteSVMProvider(client, new Wallet(otherAdmin)) + maliciousProvider = new LiteSVMProvider(client, new Wallet(malicious)) + + program = new Program(ControllerIDL as any, deployerProvider) + + deployerSdk = new ControllerSDK(deployerProvider) + adminSdk = new ControllerSDK(adminProvider) + otherAdminSdk = new ControllerSDK(otherAdminProvider) + maliciousSdk = new ControllerSDK(maliciousProvider) + + deployerProvider.client.airdrop(deployer.publicKey, toLamports(100)) + deployerProvider.client.airdrop(admin.publicKey, toLamports(100)) + deployerProvider.client.airdrop(otherAdmin.publicKey, toLamports(100)) + deployerProvider.client.airdrop(malicious.publicKey, toLamports(100)) + + // Warp so that we're not at t=0 + warpSeconds(deployerProvider, 100) + }) + + beforeEach(() => { + client.expireBlockhash() + }) + + describe('initialize', () => { + context('when caller is not deployer', async () => { + it('cannot initialize', async () => { + const newAdmin = randomPubkey() + + const ix = await maliciousSdk.initializeIx(newAdmin) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, 'Only deployer can call this instruction') + }) + }) + + context('when caller is deployer', async () => { + it('should initialize', async () => { + const ix = await deployerSdk.initializeIx(admin.publicKey) + await makeTxSignAndSend(deployerProvider, ix) + + const settings = await program.account.globalSettings.fetch(deployerSdk.getGlobalSettingsPubkey()) + expect(settings.admin.toString()).to.be.eq(admin.publicKey.toString()) + }) + + it('cannot call initialize again', async () => { + const ix = await deployerSdk.initializeIx(admin.publicKey) + const res = await makeTxSignAndSend(deployerProvider, ix) + + expectTransactionError(res, 'already in use') + }) + }) + }) + + describe('set admin', () => { + context('when caller is not admin', async () => { + it('cannot set admin', async () => { + const newAdmin = randomPubkey() + + const ix = await maliciousSdk.setAdmin(newAdmin) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, 'Only admin can call this instruction') + }) + }) + + context('when caller is admin', async () => { + after('reset admin to original for subsequent tests', async () => { + const resetIx = await otherAdminSdk.setAdmin(admin.publicKey) + await makeTxSignAndSend(otherAdminProvider, resetIx) + }) + + it('can set admin', async () => { + const ix = await adminSdk.setAdmin(otherAdmin.publicKey) + await makeTxSignAndSend(adminProvider, ix) + + const settings = await program.account.globalSettings.fetch(adminSdk.getGlobalSettingsPubkey()) + expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) + }) + }) + }) + + describe('EntityRegistry management', () => { + const validator = randomPubkey() + const axia = randomPubkey() + const solver = randomPubkey() + const validator2 = randomPubkey() + const axia2 = randomPubkey() + const solver2 = randomPubkey() + + context('when the caller is not admin', async () => { + it('cannot create registry', async () => { + const ix = await maliciousSdk.setAllowedEntityIx(EntityType.Validator, validator) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, 'Only admin can call this instruction') + }) + }) + + context('when the caller is admin', async () => { + it('should create entity registry successfully (validator)', async () => { + const ix = await adminSdk.setAllowedEntityIx(EntityType.Validator, validator) + await makeTxSignAndSend(adminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + adminSdk.getEntityRegistryPubkey(EntityType.Validator, validator) + ) + + expect(entityRegistry.entityType).to.deep.include({ validator: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(validator.toString()) + }) + + it('should create entity registry successfully (axia)', async () => { + const ix = await adminSdk.setAllowedEntityIx(EntityType.Axia, axia) + await makeTxSignAndSend(adminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + adminSdk.getEntityRegistryPubkey(EntityType.Axia, axia) + ) + + expect(entityRegistry.entityType).to.deep.include({ axia: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(axia.toString()) + }) + + it('should create entity registry successfully (solver)', async () => { + const ix = await adminSdk.setAllowedEntityIx(EntityType.Solver, solver) + await makeTxSignAndSend(adminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + adminSdk.getEntityRegistryPubkey(EntityType.Solver, solver) + ) + + expect(entityRegistry.entityType).to.deep.include({ solver: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(solver.toString()) + }) + }) + + context('when the admin is changed and caller is new admin', async () => { + before('change admin for next tests', async () => { + const ix = await adminSdk.setAdmin(otherAdmin.publicKey) + await makeTxSignAndSend(adminProvider, ix) + }) + + context('when the admin was changed', async () => { + it('should have the new admin as admin', async () => { + const settings = await program.account.globalSettings.fetch(adminSdk.getGlobalSettingsPubkey()) + expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) + }) + }) + + context('when closing entity registries', async () => { + it('should close entity registry (validator)', async () => { + const ix = await otherAdminSdk.closeEntityRegistryIx(EntityType.Validator, validator) + await makeTxSignAndSend(otherAdminProvider, ix) + + try { + await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Validator, validator) + ) + expect.fail('Entity registry should not exist after closing') + } catch (error: any) { + expect(error.message).to.include('Account does not exist') + } + }) + + it('should close entity registry (axia)', async () => { + const ix = await otherAdminSdk.closeEntityRegistryIx(EntityType.Axia, axia) + await makeTxSignAndSend(otherAdminProvider, ix) + + try { + await program.account.entityRegistry.fetch(otherAdminSdk.getEntityRegistryPubkey(EntityType.Axia, axia)) + expect.fail('Entity registry should not exist after closing') + } catch (error: any) { + expect(error.message).to.include('Account does not exist') + } + }) + + it('should close entity registry (solver)', async () => { + const ix = await otherAdminSdk.closeEntityRegistryIx(EntityType.Solver, solver) + await makeTxSignAndSend(otherAdminProvider, ix) + + try { + await program.account.entityRegistry.fetch(otherAdminSdk.getEntityRegistryPubkey(EntityType.Solver, solver)) + expect.fail('Entity registry should not exist after closing') + } catch (error: any) { + expect(error.message).to.include('Account does not exist') + } + }) + }) + + context('when allowing entities after closing their registries', async () => { + it('should create entity registry after closing (validator)', async () => { + const ix = await otherAdminSdk.setAllowedEntityIx(EntityType.Validator, validator) + await makeTxSignAndSend(otherAdminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Validator, validator) + ) + + expect(entityRegistry.entityType).to.deep.include({ validator: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(validator.toString()) + }) + + it('should create entity registry after closing (axia)', async () => { + const ix = await otherAdminSdk.setAllowedEntityIx(EntityType.Axia, axia) + await makeTxSignAndSend(otherAdminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Axia, axia) + ) + + expect(entityRegistry.entityType).to.deep.include({ axia: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(axia.toString()) + }) + + it('should create entity registry after closing (solver)', async () => { + const ix = await otherAdminSdk.setAllowedEntityIx(EntityType.Solver, solver) + await makeTxSignAndSend(otherAdminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Solver, solver) + ) + + expect(entityRegistry.entityType).to.deep.include({ solver: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(solver.toString()) + }) + }) + + context('when allowing other entities', async () => { + it('should create another validator registry', async () => { + const ix = await otherAdminSdk.setAllowedEntityIx(EntityType.Validator, validator2) + await makeTxSignAndSend(otherAdminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Validator, validator2) + ) + expect(entityRegistry.entityType).to.deep.include({ validator: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(validator2.toString()) + }) + + it('should create another axia registry', async () => { + const ix = await otherAdminSdk.setAllowedEntityIx(EntityType.Axia, axia2) + await makeTxSignAndSend(otherAdminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Axia, axia2) + ) + expect(entityRegistry.entityType).to.deep.include({ axia: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(axia2.toString()) + }) + + it('should create another solver registry', async () => { + const ix = await otherAdminSdk.setAllowedEntityIx(EntityType.Solver, solver2) + await makeTxSignAndSend(otherAdminProvider, ix) + + const entityRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Solver, solver2) + ) + expect(entityRegistry.entityType).to.deep.include({ solver: {} }) + expect(entityRegistry.entityPubkey.toString()).to.be.eq(solver2.toString()) + }) + }) + + context('when allowing entities for multiple roles', async () => { + it('should create separate accounts for same pubkey with different entity types', async () => { + const ix1 = await otherAdminSdk.setAllowedEntityIx(EntityType.Validator, axia) + await makeTxSignAndSend(otherAdminProvider, ix1) + + const validatorRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Validator, axia) + ) + const axiaRegistry = await program.account.entityRegistry.fetch( + otherAdminSdk.getEntityRegistryPubkey(EntityType.Axia, axia) + ) + + expect(validatorRegistry.entityType).to.deep.include({ validator: {} }) + expect(axiaRegistry.entityType).to.deep.include({ axia: {} }) + + const validatorPda = otherAdminSdk.getEntityRegistryPubkey(EntityType.Validator, axia) + const axiaPda = otherAdminSdk.getEntityRegistryPubkey(EntityType.Axia, axia) + expect(validatorPda.toString()).to.not.eq(axiaPda.toString()) + }) + }) + }) + }) +}) diff --git a/packages/svm/tests/helpers/helpers.ts b/packages/svm/tests/helpers/helpers.ts new file mode 100644 index 0000000..717d553 --- /dev/null +++ b/packages/svm/tests/helpers/helpers.ts @@ -0,0 +1,33 @@ +import { web3 } from '@coral-xyz/anchor' +import { expect } from 'chai' +import { FailedTransactionMetadata, TransactionMetadata } from 'litesvm' + +export const LAMPORTS_PER_SOL = 1_000_000_000 + +/** + * Helper to expect transaction errors consistently + */ +export function expectTransactionError( + res: TransactionMetadata | FailedTransactionMetadata | string, + expectedMessage: string +): void { + expect(typeof res).to.not.be.eq('TransactionMetadata') + + if (typeof res === 'string') { + expect(res).to.include(expectedMessage) + } else { + expect(res.toString()).to.include(expectedMessage) + } +} + +export function toLamports(sol: number): bigint { + return BigInt(sol * LAMPORTS_PER_SOL) +} + +export function randomKeypair(): web3.Keypair { + return web3.Keypair.generate() +} + +export function randomPubkey(): web3.PublicKey { + return randomKeypair().publicKey +} diff --git a/packages/svm/tests/settler.test.ts b/packages/svm/tests/settler.test.ts deleted file mode 100644 index d5ff4ce..0000000 --- a/packages/svm/tests/settler.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ - -import { Program, Wallet } from '@coral-xyz/anchor' -import { Keypair } from '@solana/web3.js' -import { fromWorkspace, LiteSVMProvider } from 'anchor-litesvm' -import { expect } from 'chai' -import path from 'path' - -import * as SettlerIDL from '../target/idl/settler.json' -import { Settler } from '../target/types/settler' -import { extractLogs } from './utils' - -describe('Settler Program', () => { - let client: any - let provider: LiteSVMProvider - let admin: Keypair - let malicious: Keypair - let program: Program - - before(async () => { - admin = Keypair.generate() - malicious = Keypair.generate() - - client = fromWorkspace(path.join(__dirname, '../')).withBuiltins() - - provider = new LiteSVMProvider(client, new Wallet(admin)) - program = new Program(SettlerIDL as any, provider) - - // Airdrop initial lamports - provider.client.airdrop(admin.publicKey, BigInt(100_000_000_000)) - provider.client.airdrop(malicious.publicKey, BigInt(100_000_000_000)) - }) - - describe('Settler', () => { - it('should call initialize', async () => { - const tx = await program.methods.initialize().transaction() - tx.recentBlockhash = provider.client.latestBlockhash() - tx.feePayer = admin.publicKey - tx.sign(admin) - const res = provider.client.sendTransaction(tx) - - expect(extractLogs(res.toString()).join('').includes(`Greetings from: ${program.programId.toString()}`)).to.be.ok - }) - }) -}) diff --git a/packages/svm/tests/utils.ts b/packages/svm/tests/utils.ts index 54c2529..75bdbad 100644 --- a/packages/svm/tests/utils.ts +++ b/packages/svm/tests/utils.ts @@ -1,6 +1,37 @@ -export function extractLogs(liteSvmTxMetadataString: string): string[] { - const logsMatch = liteSvmTxMetadataString.match(/logs: \[(.*?)\],/s) - if (!logsMatch) return [] +import { web3 } from '@coral-xyz/anchor' +import { LiteSVMProvider } from 'anchor-litesvm' +import { Clock, FailedTransactionMetadata, TransactionMetadata } from 'litesvm' - return logsMatch[1].split('", "') +export async function signAndSendTx( + provider: LiteSVMProvider, + tx: web3.Transaction +): Promise { + tx.recentBlockhash = provider.client.latestBlockhash() + tx.feePayer = provider.wallet.publicKey + const stx = await provider.wallet.signTransaction(tx) + return provider.client.sendTransaction(stx) +} + +export function makeTx(...ixs: web3.TransactionInstruction[]): web3.Transaction { + return new web3.Transaction().add(...ixs) +} + +export async function makeTxSignAndSend( + provider: LiteSVMProvider, + ...ixs: web3.TransactionInstruction[] +): Promise { + return signAndSendTx(provider, makeTx(...ixs)) +} + +export function warpSeconds(provider: LiteSVMProvider, seconds: number): void { + const clock = provider.client.getClock() + provider.client.setClock( + new Clock( + clock.slot, + clock.epochStartTimestamp, + clock.epoch, + clock.leaderScheduleEpoch, + clock.unixTimestamp + BigInt(seconds) + ) + ) }