From 5de59bbf3042bca3ac6f1f0ded592f735c609d3d Mon Sep 17 00:00:00 2001 From: GuidoDipietro Date: Tue, 6 Jan 2026 13:11:51 -0300 Subject: [PATCH 1/3] Settler: Intent lifecycle Rebased onto solana/settler after squash merge of solana/1-whitelist. Includes all changes from solana/2-settler-intent-lifecycle branch. --- packages/svm/idls/controller.json | 358 ++++---- packages/svm/package.json | 1 + .../src/instructions/close_entity_registry.rs | 8 +- .../controller/src/instructions/initialize.rs | 14 +- .../controller/src/instructions/set_admin.rs | 12 +- .../src/instructions/set_allowed_entity.rs | 8 +- ...bal_settings.rs => controller_settings.rs} | 2 +- .../svm/programs/controller/src/state/mod.rs | 4 +- packages/svm/programs/settler/Cargo.toml | 9 +- .../svm/programs/settler/src/constants.rs | 4 + packages/svm/programs/settler/src/errors.rs | 79 ++ .../src/instructions/claim_stale_intent.rs | 27 + .../settler/src/instructions/create_intent.rs | 81 ++ .../settler/src/instructions/extend_intent.rs | 55 ++ .../settler/src/instructions/initialize.rs | 36 + .../programs/settler/src/instructions/mod.rs | 9 + packages/svm/programs/settler/src/lib.rs | 60 +- .../settler/src/state/fulfilled_intent.rs | 5 + .../svm/programs/settler/src/state/intent.rs | 94 +++ .../svm/programs/settler/src/state/mod.rs | 7 + .../settler/src/state/settler_settings.rs | 8 + .../settler/src/types/intent_event.rs | 13 + .../svm/programs/settler/src/types/max_fee.rs | 11 + .../svm/programs/settler/src/types/mod.rs | 7 + .../svm/programs/settler/src/types/op_type.rs | 9 + .../svm/programs/settler/src/utils/math.rs | 17 + .../svm/programs/settler/src/utils/mod.rs | 3 + packages/svm/sdks/controller/Controller.ts | 20 +- packages/svm/sdks/settler/Settler.ts | 168 ++++ packages/svm/sdks/settler/types.ts | 36 + packages/svm/tests/controller.test.ts | 55 +- packages/svm/tests/helpers/constants.ts | 51 ++ packages/svm/tests/helpers/helpers.ts | 86 ++ packages/svm/tests/settler.test.ts | 781 ++++++++++++++++++ yarn.lock | 36 +- 35 files changed, 1908 insertions(+), 266 deletions(-) rename packages/svm/programs/controller/src/state/{global_settings.rs => controller_settings.rs} (76%) create mode 100644 packages/svm/programs/settler/src/constants.rs create mode 100644 packages/svm/programs/settler/src/errors.rs create mode 100644 packages/svm/programs/settler/src/instructions/claim_stale_intent.rs create mode 100644 packages/svm/programs/settler/src/instructions/create_intent.rs create mode 100644 packages/svm/programs/settler/src/instructions/extend_intent.rs create mode 100644 packages/svm/programs/settler/src/instructions/initialize.rs create mode 100644 packages/svm/programs/settler/src/instructions/mod.rs create mode 100644 packages/svm/programs/settler/src/state/fulfilled_intent.rs create mode 100644 packages/svm/programs/settler/src/state/intent.rs create mode 100644 packages/svm/programs/settler/src/state/mod.rs create mode 100644 packages/svm/programs/settler/src/state/settler_settings.rs create mode 100644 packages/svm/programs/settler/src/types/intent_event.rs create mode 100644 packages/svm/programs/settler/src/types/max_fee.rs create mode 100644 packages/svm/programs/settler/src/types/mod.rs create mode 100644 packages/svm/programs/settler/src/types/op_type.rs create mode 100644 packages/svm/programs/settler/src/utils/math.rs create mode 100644 packages/svm/programs/settler/src/utils/mod.rs create mode 100644 packages/svm/sdks/settler/Settler.ts create mode 100644 packages/svm/sdks/settler/types.ts create mode 100644 packages/svm/tests/helpers/constants.ts create mode 100644 packages/svm/tests/settler.test.ts diff --git a/packages/svm/idls/controller.json b/packages/svm/idls/controller.json index 71f8265..e4494a5 100644 --- a/packages/svm/idls/controller.json +++ b/packages/svm/idls/controller.json @@ -4,30 +4,36 @@ "name": "controller", "version": "0.1.0", "spec": "0.1.0", - "description": "Created with Anchor" + "description": "Manages allowlist for Mimic Protocol entities" }, "instructions": [ { - "name": "initialize", + "name": "close_entity_registry", "discriminator": [ - 175, - 175, - 109, - 31, - 13, - 152, - 155, - 237 + 107, + 232, + 103, + 154, + 200, + 121, + 184, + 37 ], "accounts": [ { - "name": "deployer", + "name": "admin", "writable": true, - "signer": true + "signer": true, + "relations": [ + "global_settings" + ] + }, + { + "name": "entity_registry", + "writable": true }, { "name": "global_settings", - "writable": true, "pda": { "seeds": [ { @@ -60,26 +66,30 @@ ], "args": [ { - "name": "admin", - "type": "pubkey" + "name": "entity_type", + "type": { + "defined": { + "name": "EntityType" + } + } }, { - "name": "proposed_admin_cooldown", - "type": "u64" + "name": "entity_pubkey", + "type": "pubkey" } ] }, { - "name": "propose_admin", + "name": "create_entity_registry", "discriminator": [ - 121, - 214, - 199, - 212, - 87, - 39, - 117, - 234 + 59, + 253, + 73, + 126, + 171, + 188, + 172, + 156 ], "accounts": [ { @@ -90,9 +100,12 @@ "global_settings" ] }, + { + "name": "entity_registry", + "writable": true + }, { "name": "global_settings", - "writable": true, "pda": { "seeds": [ { @@ -117,42 +130,48 @@ } ] } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" } ], "args": [ { - "name": "proposed_admin", + "name": "entity_type", + "type": { + "defined": { + "name": "EntityType" + } + } + }, + { + "name": "entity_pubkey", "type": "pubkey" } ] }, { - "name": "set_entity_allowlist_status", + "name": "initialize", "discriminator": [ - 100, - 20, - 23, - 73, - 220, - 118, - 179, - 50 + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 ], "accounts": [ { - "name": "admin", + "name": "deployer", "writable": true, - "signer": true, - "relations": [ - "global_settings" - ] - }, - { - "name": "entity_registry", - "writable": true + "signer": true }, { "name": "global_settings", + "writable": true, "pda": { "seeds": [ { @@ -185,44 +204,31 @@ ], "args": [ { - "name": "entity_type", - "type": { - "defined": { - "name": "EntityType" - } - } - }, - { - "name": "entity_pubkey", + "name": "admin", "type": "pubkey" - }, - { - "name": "status", - "type": { - "defined": { - "name": "AllowlistStatus" - } - } } ] }, { - "name": "set_proposed_admin", + "name": "set_admin", "discriminator": [ - 160, - 170, - 199, - 240, - 246, - 244, - 199, - 2 + 251, + 163, + 0, + 52, + 91, + 194, + 187, + 92 ], "accounts": [ { - "name": "proposed_admin", + "name": "admin", "writable": true, - "signer": true + "signer": true, + "relations": [ + "global_settings" + ] }, { "name": "global_settings", @@ -253,10 +259,28 @@ } } ], - "args": [] + "args": [ + { + "name": "new_admin", + "type": "pubkey" + } + ] } ], "accounts": [ + { + "name": "ControllerSettings", + "discriminator": [ + 15, + 70, + 199, + 238, + 59, + 33, + 179, + 47 + ] + }, { "name": "EntityRegistry", "discriminator": [ @@ -269,46 +293,33 @@ 158, 163 ] - }, - { - "name": "GlobalSettings", - "discriminator": [ - 109, - 67, - 50, - 55, - 2, - 20, - 148, - 62 - ] } ], "events": [ { - "name": "SetEntityAllowlistStatusEvent", + "name": "CloseEntityRegistryEvent", "discriminator": [ - 137, - 194, - 109, - 101, - 80, - 30, - 4, - 114 + 133, + 120, + 94, + 102, + 119, + 102, + 166, + 228 ] }, { - "name": "SetProposedAdminEvent", + "name": "SetAdminEvent", "discriminator": [ - 153, - 83, - 248, - 103, + 240, + 117, + 204, + 254, + 89, + 150, 132, - 126, - 171, - 96 + 94 ] } ], @@ -322,41 +333,11 @@ "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", + "name": "CloseEntityRegistryEvent", "type": { "kind": "struct", "fields": [ @@ -373,50 +354,14 @@ "type": "pubkey" }, { - "name": "status", - "type": { - "defined": { - "name": "AllowlistStatus" - } - } - }, - { - "name": "last_update", + "name": "timestamp", "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", + "name": "ControllerSettings", "type": { "kind": "struct", "fields": [ @@ -424,20 +369,6 @@ "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" @@ -446,7 +377,7 @@ } }, { - "name": "SetEntityAllowlistStatusEvent", + "name": "EntityRegistry", "type": { "kind": "struct", "fields": [ @@ -463,53 +394,44 @@ "type": "pubkey" }, { - "name": "status", - "type": { - "defined": { - "name": "AllowlistStatus" - } - } - }, - { - "name": "timestamp", - "type": "u64" - }, - { - "name": "updated_by", - "type": "pubkey" + "name": "bump", + "type": "u8" } ] } }, { - "name": "SetProposedAdminEvent", + "name": "EntityType", + "repr": { + "kind": "rust" + }, "type": { - "kind": "struct", - "fields": [ + "kind": "enum", + "variants": [ { - "name": "old_admin", - "type": "pubkey" + "name": "Validator" }, { - "name": "new_admin", - "type": "pubkey" + "name": "Axia" + }, + { + "name": "Solver" } ] } }, { - "name": "AllowlistStatus", - "repr": { - "kind": "rust" - }, + "name": "SetAdminEvent", "type": { - "kind": "enum", - "variants": [ + "kind": "struct", + "fields": [ { - "name": "Allowlisted" + "name": "new_admin", + "type": "pubkey" }, { - "name": "Blacklisted" + "name": "timestamp", + "type": "u64" } ] } diff --git a/packages/svm/package.json b/packages/svm/package.json index 9aeca27..8040005 100644 --- a/packages/svm/package.json +++ b/packages/svm/package.json @@ -18,6 +18,7 @@ "litesvm": "=0.1.0" }, "devDependencies": { + "@mimicprotocol/sdk": "0.0.1-rc.20", "@types/bn.js": "^5.1.0", "@types/chai": "^4.3.20", "@types/mocha": "^10.0.10", diff --git a/packages/svm/programs/controller/src/instructions/close_entity_registry.rs b/packages/svm/programs/controller/src/instructions/close_entity_registry.rs index 91390bf..d14903d 100644 --- a/packages/svm/programs/controller/src/instructions/close_entity_registry.rs +++ b/packages/svm/programs/controller/src/instructions/close_entity_registry.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use crate::{ errors::ControllerError, - state::{EntityRegistry, GlobalSettings}, + state::{ControllerSettings, EntityRegistry}, types::EntityType, }; @@ -21,11 +21,11 @@ pub struct CloseEntityRegistry<'info> { pub entity_registry: Box>, #[account( - seeds = [b"global-settings"], - bump = global_settings.bump, + seeds = [b"controller-settings"], + bump = controller_settings.bump, has_one = admin @ ControllerError::OnlyAdmin )] - pub global_settings: Box>, + pub controller_settings: Box>, pub system_program: Program<'info, System>, } diff --git a/packages/svm/programs/controller/src/instructions/initialize.rs b/packages/svm/programs/controller/src/instructions/initialize.rs index 094cd0a..b565f07 100644 --- a/packages/svm/programs/controller/src/instructions/initialize.rs +++ b/packages/svm/programs/controller/src/instructions/initialize.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use std::str::FromStr; -use crate::{constants::DEPLOYER_KEY, errors::ControllerError, state::GlobalSettings}; +use crate::{constants::DEPLOYER_KEY, errors::ControllerError, state::ControllerSettings}; #[derive(Accounts)] pub struct Initialize<'info> { @@ -10,12 +10,12 @@ pub struct Initialize<'info> { #[account( init, - seeds = [b"global-settings"], + seeds = [b"controller-settings"], bump, payer = deployer, - space = 8 + GlobalSettings::INIT_SPACE + space = 8 + ControllerSettings::INIT_SPACE )] - pub global_settings: Box>, + pub controller_settings: Box>, pub system_program: Program<'info, System>, } @@ -27,10 +27,10 @@ pub fn initialize(ctx: Context, admin: Pubkey) -> Result<()> { ControllerError::OnlyDeployer, ); - let global_settings = &mut ctx.accounts.global_settings; + let controller_settings = &mut ctx.accounts.controller_settings; - global_settings.admin = admin; - global_settings.bump = ctx.bumps.global_settings; + controller_settings.admin = admin; + controller_settings.bump = ctx.bumps.controller_settings; Ok(()) } diff --git a/packages/svm/programs/controller/src/instructions/set_admin.rs b/packages/svm/programs/controller/src/instructions/set_admin.rs index dc59043..8e44a34 100644 --- a/packages/svm/programs/controller/src/instructions/set_admin.rs +++ b/packages/svm/programs/controller/src/instructions/set_admin.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::{errors::ControllerError, state::GlobalSettings}; +use crate::{errors::ControllerError, state::ControllerSettings}; #[derive(Accounts)] pub struct SetAdmin<'info> { @@ -9,17 +9,17 @@ pub struct SetAdmin<'info> { #[account( mut, - seeds = [b"global-settings"], - bump = global_settings.bump, + seeds = [b"controller-settings"], + bump = controller_settings.bump, has_one = admin @ ControllerError::OnlyAdmin )] - pub global_settings: Box>, + pub controller_settings: Box>, } pub fn set_admin(ctx: Context, new_admin: Pubkey) -> Result<()> { - let global_settings = &mut ctx.accounts.global_settings; + let controller_settings = &mut ctx.accounts.controller_settings; - global_settings.admin = new_admin; + controller_settings.admin = new_admin; emit!(SetAdminEvent { new_admin, diff --git a/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs b/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs index f264eea..f5e8966 100644 --- a/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs +++ b/packages/svm/programs/controller/src/instructions/set_allowed_entity.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; use crate::{ errors::ControllerError, - state::{EntityRegistry, GlobalSettings}, + state::{ControllerSettings, EntityRegistry}, types::EntityType, }; @@ -22,11 +22,11 @@ pub struct SetAllowedEntity<'info> { pub entity_registry: Box>, #[account( - seeds = [b"global-settings"], - bump = global_settings.bump, + seeds = [b"controller-settings"], + bump = controller_settings.bump, has_one = admin @ ControllerError::OnlyAdmin )] - pub global_settings: Box>, + pub controller_settings: Box>, pub system_program: Program<'info, System>, } diff --git a/packages/svm/programs/controller/src/state/global_settings.rs b/packages/svm/programs/controller/src/state/controller_settings.rs similarity index 76% rename from packages/svm/programs/controller/src/state/global_settings.rs rename to packages/svm/programs/controller/src/state/controller_settings.rs index 6027636..3c8d463 100644 --- a/packages/svm/programs/controller/src/state/global_settings.rs +++ b/packages/svm/programs/controller/src/state/controller_settings.rs @@ -2,7 +2,7 @@ use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] -pub struct GlobalSettings { +pub struct ControllerSettings { 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 index e0bef0c..1ad604e 100644 --- a/packages/svm/programs/controller/src/state/mod.rs +++ b/packages/svm/programs/controller/src/state/mod.rs @@ -1,5 +1,5 @@ +pub mod controller_settings; pub mod entity_registry; -pub mod global_settings; +pub use controller_settings::*; pub use entity_registry::*; -pub use global_settings::*; diff --git a/packages/svm/programs/settler/Cargo.toml b/packages/svm/programs/settler/Cargo.toml index 899599c..663847b 100644 --- a/packages/svm/programs/settler/Cargo.toml +++ b/packages/svm/programs/settler/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "settler" version = "0.1.0" -description = "Created with Anchor" +description = "Manages on-chain intent settlement for Mimic protocol" edition = "2021" +license = "GPL-3.0" [lib] crate-type = ["cdylib", "lib"] @@ -15,6 +16,12 @@ 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/settler/src/constants.rs b/packages/svm/programs/settler/src/constants.rs new file mode 100644 index 0000000..c2edf8e --- /dev/null +++ b/packages/svm/programs/settler/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/settler/src/errors.rs b/packages/svm/programs/settler/src/errors.rs new file mode 100644 index 0000000..0c008e0 --- /dev/null +++ b/packages/svm/programs/settler/src/errors.rs @@ -0,0 +1,79 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum SettlerError { + #[msg("Only Deployer can call this instruction")] + OnlyDeployer, + + #[msg("Only an allowlisted solver can call this instruction")] + OnlySolver, + + #[msg("Provided Axia address is not allowlisted")] + AxiaNotAllowlisted, + + #[msg("Only a allowlisted validator can call this instruction")] + OnlyValidator, + + #[msg("No max fees provided")] + NoMaxFees, + + #[msg("Validator is not allowlisted")] + ValidatorNotAllowlisted, + + #[msg("Signer must be intent creator")] + IncorrectIntentCreator, + + #[msg("Signer must be proposal creator")] + IncorrectProposalCreator, + + #[msg("Intent is already final")] + IntentIsFinal, + + #[msg("Intent is not final")] + IntentIsNotFinal, + + #[msg("Proposal is already final")] + ProposalIsFinal, + + #[msg("Proposal is not final")] + ProposalIsNotFinal, + + #[msg("Intent not yet expired. Please wait for the deadline to pass")] + IntentNotYetExpired, + + #[msg("Intent has already expired")] + IntentIsExpired, + + #[msg("Proposal not yet expired. Please wait for the deadline to pass")] + ProposalNotYetExpired, + + #[msg("Proposal has already expired")] + ProposalIsExpired, + + #[msg("Deadline must be in the future")] + DeadlineIsInThePast, + + #[msg("Proposal deadline can't be after the Intent's deadline")] + ProposalDeadlineExceedsIntentDeadline, + + #[msg("Intent has insufficient validations")] + InsufficientIntentValidations, + + #[msg("Signature verification failed")] + SigVerificationFailed, + + #[msg("Incorrect intent for proposal")] + IncorrectIntentForProposal, + + #[msg("Proposal is not signed by Axia")] + ProposalIsNotSigned, + + #[msg("Invalid fee mint")] + InvalidFeeMint, + + #[msg("Fee amount exceeds max fee")] + FeeAmountExceedsMaxFee, + + #[msg("Math Error")] + MathError, +} diff --git a/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs b/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs new file mode 100644 index 0000000..d5b397e --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs @@ -0,0 +1,27 @@ +use anchor_lang::prelude::*; + +use crate::{errors::SettlerError, state::Intent}; + +#[derive(Accounts)] +pub struct ClaimStaleIntent<'info> { + #[account(mut)] + pub creator: Signer<'info>, + + #[account( + mut, + close = creator, + has_one = creator @ SettlerError::IncorrectIntentCreator, + )] + pub intent: Box>, +} + +pub fn claim_stale_intent(ctx: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + + require!( + ctx.accounts.intent.deadline < now, + SettlerError::IntentNotYetExpired + ); + + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/create_intent.rs b/packages/svm/programs/settler/src/instructions/create_intent.rs new file mode 100644 index 0000000..f830b88 --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/create_intent.rs @@ -0,0 +1,81 @@ +use anchor_lang::prelude::*; + +use crate::{ + controller::{self, accounts::EntityRegistry, types::EntityType}, + errors::SettlerError, + state::Intent, + types::{IntentEvent, OpType, TokenFee}, +}; + +#[derive(Accounts)] +// TODO: can we optimize this deser? we just need the three Vec for their length +#[instruction(intent_hash: [u8; 32], data: Vec, max_fees: Vec, events: Vec, min_validations: u16)] +pub struct CreateIntent<'info> { + #[account(mut)] + pub solver: Signer<'info>, + + #[account( + seeds = [b"entity-registry", &[EntityType::Solver as u8 + 1], solver.key().as_ref()], + bump = solver_registry.bump, + seeds::program = controller::ID + )] + pub solver_registry: Box>, + + #[account( + init, + seeds = [b"intent", intent_hash.as_ref()], + bump, + payer = solver, + space = Intent::total_size(data.len(), max_fees.len(), &events, min_validations)? + )] + // TODO: change to AccountLoader? + // TODO: init within the handler body to save compute? + pub intent: Box>, + + #[account( + seeds = [b"fulfilled-intent", intent_hash.as_ref()], + bump + )] + /// This PDA must be uninitialized + pub fulfilled_intent: SystemAccount<'info>, + + pub system_program: Program<'info, System>, +} + +pub fn create_intent( + ctx: Context, + intent_hash: [u8; 32], + data: Vec, + max_fees: Vec, + events: Vec, + min_validations: u16, + op: OpType, + user: Pubkey, + nonce: [u8; 32], + deadline: u64, + is_final: bool, +) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + require!(deadline > now, SettlerError::DeadlineIsInThePast); + require!(max_fees.len() > 0, SettlerError::NoMaxFees); + + // TODO: check hash + + let intent = &mut ctx.accounts.intent; + + intent.op = op; + intent.user = user; + intent.creator = ctx.accounts.solver.key(); + intent.hash = intent_hash; + intent.nonce = nonce; + intent.deadline = deadline; + intent.min_validations = min_validations; + intent.is_final = is_final; + intent.data = data; + intent.max_fees = max_fees; + intent.events = events; + intent.validators = vec![]; + intent.bump = ctx.bumps.intent; + + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/extend_intent.rs b/packages/svm/programs/settler/src/instructions/extend_intent.rs new file mode 100644 index 0000000..46b8f05 --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/extend_intent.rs @@ -0,0 +1,55 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::SettlerError, + state::Intent, + types::{IntentEvent, TokenFee}, +}; + +#[derive(Accounts)] +#[instruction(more_data: Option>, more_max_fees: Option>, more_events: Option>)] +pub struct ExtendIntent<'info> { + #[account(mut)] + pub creator: Signer<'info>, + + #[account( + mut, + has_one = creator @ SettlerError::IncorrectIntentCreator, + constraint = !intent.is_final @ SettlerError::IntentIsFinal, + realloc = + Intent::extended_size(intent.to_account_info().data_len(), &more_data, &more_max_fees, &more_events)?, + realloc::payer = creator, + realloc::zero = true + )] + pub intent: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn extend_intent( + ctx: Context, + more_data: Option>, + more_max_fees: Option>, + more_events: Option>, + finalize: bool, +) -> Result<()> { + let intent = &mut ctx.accounts.intent; + + if let Some(_more_data) = more_data { + intent.data.extend_from_slice(&_more_data); + } + + if let Some(_more_max_fees) = more_max_fees { + intent.max_fees.extend_from_slice(&_more_max_fees); + } + + if let Some(_more_events) = more_events { + intent.events.extend_from_slice(&_more_events); + } + + if finalize { + intent.is_final = true; + } + + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/initialize.rs b/packages/svm/programs/settler/src/instructions/initialize.rs new file mode 100644 index 0000000..4f13472 --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/initialize.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; +use std::str::FromStr; + +use crate::{constants::DEPLOYER_KEY, controller, errors::SettlerError, state::SettlerSettings}; + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(mut)] + pub deployer: Signer<'info>, + + #[account( + init, + seeds = [b"settler-settings"], + bump, + payer = deployer, + space = 8 + SettlerSettings::INIT_SPACE, + )] + pub settler_settings: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn initialize(ctx: Context) -> Result<()> { + require_keys_eq!( + ctx.accounts.deployer.key(), + Pubkey::from_str(DEPLOYER_KEY).unwrap(), + SettlerError::OnlyDeployer, + ); + + let settler_settings = &mut ctx.accounts.settler_settings; + + settler_settings.controller_program = controller::ID; + settler_settings.bump = ctx.bumps.settler_settings; + + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/mod.rs b/packages/svm/programs/settler/src/instructions/mod.rs new file mode 100644 index 0000000..a7aa8cc --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/mod.rs @@ -0,0 +1,9 @@ +pub mod claim_stale_intent; +pub mod create_intent; +pub mod extend_intent; +pub mod initialize; + +pub use claim_stale_intent::*; +pub use create_intent::*; +pub use extend_intent::*; +pub use initialize::*; diff --git a/packages/svm/programs/settler/src/lib.rs b/packages/svm/programs/settler/src/lib.rs index 59630fe..c9052c3 100644 --- a/packages/svm/programs/settler/src/lib.rs +++ b/packages/svm/programs/settler/src/lib.rs @@ -1,18 +1,64 @@ -#![allow(unexpected_cfgs)] - use anchor_lang::prelude::*; declare_id!("HbNt35Ng8aM4NUy39evpCQqXEC4Nmaq16ewY8dyNF6NF"); +declare_program!(controller); + +pub mod constants; +pub mod errors; +pub mod instructions; +pub mod state; +pub mod types; +pub mod utils; + +use crate::{instructions::*, types::*}; #[program] pub mod settler { use super::*; + pub fn claim_stale_intent(ctx: Context) -> Result<()> { + instructions::claim_stale_intent(ctx) + } + + pub fn create_intent( + ctx: Context, + intent_hash: [u8; 32], + data: Vec, + max_fees: Vec, + events: Vec, + min_validations: u16, + op: OpType, + user: Pubkey, + nonce: [u8; 32], + deadline: u64, + is_final: bool, + ) -> Result<()> { + instructions::create_intent( + ctx, + intent_hash, + data, + max_fees, + events, + min_validations, + op, + user, + nonce, + deadline, + is_final, + ) + } + + pub fn extend_intent( + ctx: Context, + more_data: Option>, + more_max_fees: Option>, + more_events: Option>, + finalize: bool, + ) -> Result<()> { + instructions::extend_intent(ctx, more_data, more_max_fees, more_events, finalize) + } + pub fn initialize(ctx: Context) -> Result<()> { - msg!("Greetings from: {:?}", ctx.program_id); - Ok(()) + instructions::initialize(ctx) } } - -#[derive(Accounts)] -pub struct Initialize {} diff --git a/packages/svm/programs/settler/src/state/fulfilled_intent.rs b/packages/svm/programs/settler/src/state/fulfilled_intent.rs new file mode 100644 index 0000000..21e3452 --- /dev/null +++ b/packages/svm/programs/settler/src/state/fulfilled_intent.rs @@ -0,0 +1,5 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct FulfilledIntent {} diff --git a/packages/svm/programs/settler/src/state/intent.rs b/packages/svm/programs/settler/src/state/intent.rs new file mode 100644 index 0000000..c8b2522 --- /dev/null +++ b/packages/svm/programs/settler/src/state/intent.rs @@ -0,0 +1,94 @@ +use anchor_lang::prelude::*; + +use crate::{ + types::{IntentEvent, OpType, TokenFee}, + utils::{add, mul, sub}, +}; + +#[account] +pub struct Intent { + pub op: OpType, + pub user: Pubkey, + pub creator: Pubkey, + pub hash: [u8; 32], + pub nonce: [u8; 32], + pub deadline: u64, + pub min_validations: u16, + pub is_final: bool, + pub validators: Vec, // TODO: how to store more efficiently? + pub data: Vec, + pub max_fees: Vec, + pub events: Vec, + pub bump: u8, +} + +impl Intent { + /// Doesn't take into account size of variable fields + pub const BASE_LEN: usize = + 1 + // op + 32 + // user + 32 + // creator + 32 + // hash + 32 + // nonce + 8 + // deadline + 2 + // min_validations + 1 + // is_final + 1 // bump + ; + + pub fn total_size( + data_len: usize, + max_fees_len: usize, + events: &[IntentEvent], + min_validations: u16, + ) -> Result { + let size = add(8, Intent::BASE_LEN)?; + let size = add(size, Intent::data_size(data_len)?)?; + let size = add(size, Intent::max_fees_size(max_fees_len)?)?; + let size = add(size, Intent::events_size(events)?)?; + let size = add(size, Intent::validators_size(min_validations)?)?; + Ok(size) + } + + pub fn data_size(len: usize) -> Result { + add(4, len) + } + + pub fn max_fees_size(len: usize) -> Result { + add(4, mul(TokenFee::INIT_SPACE, len)?) + } + + pub fn events_size(events: &[IntentEvent]) -> Result { + let sum = events + .iter() + .try_fold(0usize, |acc, e| add(acc, e.size()))?; + add(4, sum) + } + + pub fn validators_size(min_validations: u16) -> Result { + add(4, mul(min_validations as usize, 32)?) + } + + pub fn extended_size( + size: usize, + more_data: &Option>, + more_max_fees: &Option>, + more_events: &Option>, + ) -> Result { + let mut size = size; + + if let Some(v) = more_data { + size = add(size, sub(Intent::data_size(v.len())?, 4)?)?; + } + + if let Some(v) = more_max_fees { + size = add(size, sub(Intent::max_fees_size(v.len())?, 4)?)?; + } + + if let Some(v) = more_events { + size = add(size, sub(Intent::events_size(v)?, 4)?)?; + } + + Ok(size) + } +} diff --git a/packages/svm/programs/settler/src/state/mod.rs b/packages/svm/programs/settler/src/state/mod.rs new file mode 100644 index 0000000..a192198 --- /dev/null +++ b/packages/svm/programs/settler/src/state/mod.rs @@ -0,0 +1,7 @@ +pub mod fulfilled_intent; +pub mod intent; +pub mod settler_settings; + +pub use fulfilled_intent::*; +pub use intent::*; +pub use settler_settings::*; diff --git a/packages/svm/programs/settler/src/state/settler_settings.rs b/packages/svm/programs/settler/src/state/settler_settings.rs new file mode 100644 index 0000000..b026d38 --- /dev/null +++ b/packages/svm/programs/settler/src/state/settler_settings.rs @@ -0,0 +1,8 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct SettlerSettings { + pub controller_program: Pubkey, + pub bump: u8, +} diff --git a/packages/svm/programs/settler/src/types/intent_event.rs b/packages/svm/programs/settler/src/types/intent_event.rs new file mode 100644 index 0000000..0eb00c4 --- /dev/null +++ b/packages/svm/programs/settler/src/types/intent_event.rs @@ -0,0 +1,13 @@ +use anchor_lang::prelude::*; + +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub struct IntentEvent { + pub topic: [u8; 32], + pub data: Vec, +} + +impl IntentEvent { + pub fn size(&self) -> usize { + 32 + 4 + self.data.len() + } +} diff --git a/packages/svm/programs/settler/src/types/max_fee.rs b/packages/svm/programs/settler/src/types/max_fee.rs new file mode 100644 index 0000000..dc072b9 --- /dev/null +++ b/packages/svm/programs/settler/src/types/max_fee.rs @@ -0,0 +1,11 @@ +use anchor_lang::prelude::*; + +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub struct TokenFee { + pub mint: Pubkey, + pub amount: u64, +} + +impl Space for TokenFee { + const INIT_SPACE: usize = 32 + 8; +} diff --git a/packages/svm/programs/settler/src/types/mod.rs b/packages/svm/programs/settler/src/types/mod.rs new file mode 100644 index 0000000..76aa716 --- /dev/null +++ b/packages/svm/programs/settler/src/types/mod.rs @@ -0,0 +1,7 @@ +pub mod intent_event; +pub mod max_fee; +pub mod op_type; + +pub use intent_event::*; +pub use max_fee::*; +pub use op_type::*; diff --git a/packages/svm/programs/settler/src/types/op_type.rs b/packages/svm/programs/settler/src/types/op_type.rs new file mode 100644 index 0000000..fa60e8e --- /dev/null +++ b/packages/svm/programs/settler/src/types/op_type.rs @@ -0,0 +1,9 @@ +use anchor_lang::prelude::*; + +#[repr(u8)] +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub enum OpType { + Swap = 0, + Transfer = 1, + Call = 2, +} diff --git a/packages/svm/programs/settler/src/utils/math.rs b/packages/svm/programs/settler/src/utils/math.rs new file mode 100644 index 0000000..e48c97a --- /dev/null +++ b/packages/svm/programs/settler/src/utils/math.rs @@ -0,0 +1,17 @@ +use crate::errors::SettlerError; +use anchor_lang::prelude::*; + +#[inline] +pub fn add(a: usize, b: usize) -> Result { + Ok(a.checked_add(b).ok_or(SettlerError::MathError)?) +} + +#[inline] +pub fn sub(a: usize, b: usize) -> Result { + Ok(a.checked_sub(b).ok_or(SettlerError::MathError)?) +} + +#[inline] +pub fn mul(a: usize, b: usize) -> Result { + Ok(a.checked_mul(b).ok_or(SettlerError::MathError)?) +} diff --git a/packages/svm/programs/settler/src/utils/mod.rs b/packages/svm/programs/settler/src/utils/mod.rs new file mode 100644 index 0000000..ed1f5ec --- /dev/null +++ b/packages/svm/programs/settler/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod math; + +pub use math::*; diff --git a/packages/svm/sdks/controller/Controller.ts b/packages/svm/sdks/controller/Controller.ts index e2e7b72..b33e514 100644 --- a/packages/svm/sdks/controller/Controller.ts +++ b/packages/svm/sdks/controller/Controller.ts @@ -19,24 +19,24 @@ export default class ControllerSDK { } async initializeIx(admin: web3.PublicKey): Promise { - const globalSettings = this.getGlobalSettingsPubkey() + const controllerSettings = this.getControllerSettingsPubkey() const ix = await this.program.methods .initialize(admin) .accountsPartial({ deployer: this.getSignerKey(), - globalSettings, + controllerSettings, }) .instruction() return ix } async setAdmin(newAdmin: web3.PublicKey): Promise { - const globalSettings = this.getGlobalSettingsPubkey() + const controllerSettings = this.getControllerSettingsPubkey() const ix = await this.program.methods .setAdmin(newAdmin) .accountsPartial({ admin: this.getSignerKey(), - globalSettings, + controllerSettings, }) .instruction() return ix @@ -44,13 +44,13 @@ export default class ControllerSDK { async setAllowedEntityIx(entityType: EntityType, entityPubkey: web3.PublicKey): Promise { const entityRegistry = this.getEntityRegistryPubkey(entityType, entityPubkey) - const globalSettings = this.getGlobalSettingsPubkey() + const controllerSettings = this.getControllerSettingsPubkey() const ix = await this.program.methods .setAllowedEntity(this.entityTypeToAnchorEnum(entityType), entityPubkey) .accountsPartial({ admin: this.getSignerKey(), entityRegistry, - globalSettings, + controllerSettings, }) .instruction() return ix @@ -61,13 +61,13 @@ export default class ControllerSDK { entityPubkey: web3.PublicKey ): Promise { const entityRegistry = this.getEntityRegistryPubkey(entityType, entityPubkey) - const globalSettings = this.getGlobalSettingsPubkey() + const controllerSettings = this.getControllerSettingsPubkey() const ix = await this.program.methods .closeEntityRegistry(this.entityTypeToAnchorEnum(entityType), entityPubkey) .accountsPartial({ admin: this.getSignerKey(), entityRegistry, - globalSettings, + controllerSettings, }) .instruction() return ix @@ -78,8 +78,8 @@ export default class ControllerSDK { return this.program.provider.wallet?.publicKey } - getGlobalSettingsPubkey(): web3.PublicKey { - return web3.PublicKey.findProgramAddressSync([Buffer.from('global-settings')], this.program.programId)[0] + getControllerSettingsPubkey(): web3.PublicKey { + return web3.PublicKey.findProgramAddressSync([Buffer.from('controller-settings')], this.program.programId)[0] } getEntityRegistryPubkey(entityType: EntityType, entityPubkey: web3.PublicKey): web3.PublicKey { diff --git a/packages/svm/sdks/settler/Settler.ts b/packages/svm/sdks/settler/Settler.ts new file mode 100644 index 0000000..8bea75d --- /dev/null +++ b/packages/svm/sdks/settler/Settler.ts @@ -0,0 +1,168 @@ +import { BN, IdlTypes, Program, Provider, web3 } from '@coral-xyz/anchor' + +import * as ControllerIDL from '../../target/idl/controller.json' +import * as SettlerIDL from '../../target/idl/settler.json' +import { Settler } from '../../target/types/settler' +import { EntityType } from '../controller/Controller' +import { CreateIntentParams, ExtendIntentParams, IntentEvent, OpType, TokenFee } from './types' + +type TokenFeeAnchor = { + mint: web3.PublicKey + amount: BN +} + +type IntentEventAnchor = { + topic: number[] + data: Buffer +} + +export default class SettlerSDK { + protected program: Program + + constructor(provider: Provider) { + this.program = new Program(SettlerIDL, provider) + } + + async initializeIx(): Promise { + const ix = await this.program.methods.initialize().instruction() + return ix + } + + async createIntentIx( + intentHashHex: string, + params: CreateIntentParams, + isFinal = true + ): Promise { + const { op, user, nonceHex, deadline, minValidations, dataHex, maxFees, eventsHex } = params + + const intentHash = this.parseIntentHashHex(intentHashHex) + const nonce = this.parseIntentNonceHex(nonceHex) + const data = Buffer.from(dataHex, 'hex') + const maxFeesBn = this.parseTokenFees(maxFees) + const events = this.parseIntentEventsHex(eventsHex) + + const ix = await this.program.methods + .createIntent( + intentHash, + data, + maxFeesBn, + events, + minValidations, + this.opTypeToAnchorEnum(op), + user, + nonce, + new BN(deadline), + isFinal + ) + .accountsPartial({ + solver: this.getSignerKey(), + solverRegistry: this.getEntityRegistryPubkey(EntityType.Solver, this.getSignerKey()), + }) + .instruction() + + return ix + } + + async extendIntentIx( + intentHashHex: string, + params: ExtendIntentParams, + finalize = true + ): Promise { + const { moreDataHex = '', moreMaxFees = [], moreEventsHex = [] } = params + + const moreData = Buffer.from(moreDataHex, 'hex') + const moreMaxFeesBn = this.parseTokenFees(moreMaxFees) + const moreEvents = this.parseIntentEventsHex(moreEventsHex) + + const ix = await this.program.methods + .extendIntent(moreData, moreMaxFeesBn, moreEvents, finalize) + .accountsPartial({ + creator: this.getSignerKey(), + intent: this.getIntentKey(intentHashHex), + }) + .instruction() + + return ix + } + + async claimStaleIntentIx(intentHashHex: string): Promise { + const ix = await this.program.methods + .claimStaleIntent() + .accountsPartial({ + creator: this.getSignerKey(), + intent: this.getIntentKey(intentHashHex), + }) + .instruction() + + return ix + } + + getSettlerSettingsPubkey(): web3.PublicKey { + return web3.PublicKey.findProgramAddressSync([Buffer.from('settler-settings')], this.program.programId)[0] + } + + getIntentKey(intentHashHex: string): web3.PublicKey { + const intentHash = Buffer.from(intentHashHex, 'hex') + if (intentHash.length != 32) throw new Error(`Intent hash must be 32 bytes: ${intentHashHex}`) + + return web3.PublicKey.findProgramAddressSync([Buffer.from('intent'), intentHash], this.program.programId)[0] + } + + getFulfilledIntentKey(intentHashHex: string): web3.PublicKey { + const intentHash = Buffer.from(intentHashHex, 'hex') + if (intentHash.length != 32) throw new Error(`Intent hash must be 32 bytes: ${intentHashHex}`) + + return web3.PublicKey.findProgramAddressSync( + [Buffer.from('fulfilled-intent'), intentHash], + 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()], + new web3.PublicKey(ControllerIDL.address) + )[0] + } + + getSignerKey(): web3.PublicKey { + if (!this.program.provider.wallet) throw new Error('Must set program provider wallet') + return this.program.provider.wallet?.publicKey + } + + opTypeToAnchorEnum(op: OpType): IdlTypes['opType'] { + if (op === OpType.Transfer) return { transfer: {} } + if (op === OpType.Swap) return { swap: {} } + if (op === OpType.Call) return { call: {} } + + throw new Error(`Unsupported op ${op}`) + } + + private parseIntentHashHex(intentHashHex: string): number[] { + const intentHash = Buffer.from(intentHashHex, 'hex') + if (intentHash.length != 32) throw new Error(`Intent hash must be 32 bytes: ${intentHashHex}`) + return Array.from(intentHash) + } + + private parseIntentNonceHex(nonceHex: string): number[] { + const nonce = Buffer.from(nonceHex, 'hex') + if (nonce.length != 32) throw new Error(`Nonce must be 32 bytes: ${nonceHex}`) + return Array.from(nonce) + } + + private parseIntentEventsHex(eventsHex: IntentEvent[]): IntentEventAnchor[] { + const events = eventsHex.map((eventHex) => ({ + topic: Array.from(Uint8Array.from(Buffer.from(eventHex.topicHex, 'hex'))), + data: Buffer.from(eventHex.dataHex, 'hex'), + })) + if (events.some((event) => event.topic.length != 32)) throw new Error(`Event topics must be 32 bytes`) + return events + } + + private parseTokenFees(tokenFees: TokenFee[]): TokenFeeAnchor[] { + return tokenFees.map((tokenFee) => ({ + ...tokenFee, + amount: new BN(tokenFee.amount), + })) + } +} diff --git a/packages/svm/sdks/settler/types.ts b/packages/svm/sdks/settler/types.ts new file mode 100644 index 0000000..92d487a --- /dev/null +++ b/packages/svm/sdks/settler/types.ts @@ -0,0 +1,36 @@ +import { web3 } from '@coral-xyz/anchor' + +export type TokenFee = { + mint: web3.PublicKey + amount: number +} + +export type IntentEvent = { + topicHex: string + dataHex: string +} + +export const OpType = { + Transfer: 0, + Swap: 1, + Call: 2, +} as const + +export type OpType = (typeof OpType)[keyof typeof OpType] + +export type CreateIntentParams = { + op: OpType + user: web3.PublicKey + nonceHex: string + deadline: number + minValidations: number + dataHex: string + maxFees: TokenFee[] + eventsHex: IntentEvent[] +} + +export type ExtendIntentParams = { + moreDataHex?: string + moreMaxFees?: TokenFee[] + moreEventsHex?: IntentEvent[] +} diff --git a/packages/svm/tests/controller.test.ts b/packages/svm/tests/controller.test.ts index bf070a1..747d064 100644 --- a/packages/svm/tests/controller.test.ts +++ b/packages/svm/tests/controller.test.ts @@ -87,7 +87,7 @@ describe('Controller', () => { const ix = await deployerSdk.initializeIx(admin.publicKey) await makeTxSignAndSend(deployerProvider, ix) - const settings = await program.account.globalSettings.fetch(deployerSdk.getGlobalSettingsPubkey()) + const settings = await program.account.controllerSettings.fetch(deployerSdk.getControllerSettingsPubkey()) expect(settings.admin.toString()).to.be.eq(admin.publicKey.toString()) }) @@ -122,7 +122,7 @@ describe('Controller', () => { const ix = await adminSdk.setAdmin(otherAdmin.publicKey) await makeTxSignAndSend(adminProvider, ix) - const settings = await program.account.globalSettings.fetch(adminSdk.getGlobalSettingsPubkey()) + const settings = await program.account.controllerSettings.fetch(adminSdk.getControllerSettingsPubkey()) expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) }) }) @@ -181,17 +181,66 @@ describe('Controller', () => { expect(entityRegistry.entityType).to.deep.include({ solver: {} }) expect(entityRegistry.entityPubkey.toString()).to.be.eq(solver.toString()) }) + + it('should change admin for next tests', async () => { + const ix = await adminSdk.setAdmin(otherAdmin.publicKey) + await makeTxSignAndSend(adminProvider, ix) + + const settings = await program.account.controllerSettings.fetch(adminSdk.getControllerSettingsPubkey()) + expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) + }) + + 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 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) + + const settings = await program.account.controllerSettings.fetch(adminSdk.getControllerSettingsPubkey()) + expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) }) 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()) + const settings = await program.account.controllerSettings.fetch(adminSdk.getControllerSettingsPubkey()) expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) }) }) diff --git a/packages/svm/tests/helpers/constants.ts b/packages/svm/tests/helpers/constants.ts new file mode 100644 index 0000000..a7cd412 --- /dev/null +++ b/packages/svm/tests/helpers/constants.ts @@ -0,0 +1,51 @@ +// Test constants for time values (in seconds) +export const COOLDOWN_PERIOD = 3600 +export const COOLDOWN_PERIOD_PLUS_ONE = 3601 +export const INTENT_DEADLINE_OFFSET = 3600 +export const PROPOSAL_DEADLINE_OFFSET = 1800 +export const STALE_CLAIM_DELAY = 50 +export const STALE_CLAIM_DELAY_PLUS_ONE = 51 +export const SHORT_DEADLINE = 100 +export const MEDIUM_DEADLINE = 300 +export const LONG_DEADLINE = 500 +export const VERY_SHORT_DEADLINE = 10 +export const WARP_TIME_SHORT = 100 +export const WARP_TIME_MEDIUM = 300 +export const WARP_TIME_LONG = 500 +export const EXPIRATION_TEST_DELAY = 80 +export const EXPIRATION_TEST_DELAY_PLUS_ONE = 81 +export const DOUBLE_CLAIM_DELAY = 90 +export const DOUBLE_CLAIM_DELAY_PLUS_ONE = 91 + +// Test constants for amounts +export const DEFAULT_MAX_FEE = 1000 +export const DEFAULT_MAX_FEE_HALF = 500 +export const DEFAULT_MAX_FEE_EXCEED = 1500 +export const ACCOUNT_CLOSE_FEE = 5000 // Fee for closing accounts + +// Test constants for data +export const DEFAULT_DATA_HEX = '010203' +export const DEFAULT_TOPIC_HEX = Buffer.from(Array(32).fill(1)).toString('hex') +export const DEFAULT_EVENT_DATA_HEX = '040506' +export const EMPTY_DATA_HEX = '' +export const TEST_DATA_HEX_1 = '070809' +export const TEST_DATA_HEX_2 = '0a0b0c' +export const TEST_DATA_HEX_3 = 'deadbeef' + +// Test constants for validation +export const DEFAULT_MIN_VALIDATIONS = 1 +export const MULTIPLE_MIN_VALIDATIONS = 3 + +// Test constants for iterations +export const LARGE_EXTEND_ITERATIONS = 100 +export const MULTIPLE_PROPOSALS_COUNT = 20 + +// Test constants for hex string lengths +export const INTENT_HASH_LENGTH = 32 // bytes +export const NONCE_LENGTH = 32 // bytes +export const SIGNATURE_LENGTH = 64 // bytes + +// Test constants for cooldown validation +export const MAX_COOLDOWN = 3600 * 24 * 30 // 30 days +export const MAX_COOLDOWN_PLUS_ONE = MAX_COOLDOWN + 1 +export const MIN_COOLDOWN = 0 diff --git a/packages/svm/tests/helpers/helpers.ts b/packages/svm/tests/helpers/helpers.ts index 717d553..168fbdd 100644 --- a/packages/svm/tests/helpers/helpers.ts +++ b/packages/svm/tests/helpers/helpers.ts @@ -1,9 +1,95 @@ import { web3 } from '@coral-xyz/anchor' +import { randomHex } from '@mimicprotocol/sdk' +import { Keypair, PublicKey } from '@solana/web3.js' +import { LiteSVMProvider } from 'anchor-litesvm' import { expect } from 'chai' import { FailedTransactionMetadata, TransactionMetadata } from 'litesvm' +import SettlerSDK from '../../sdks/settler/Settler' +import { CreateIntentParams, IntentEvent, OpType, TokenFee } from '../../sdks/settler/types' +import { makeTxSignAndSend } from '../utils' +import { + DEFAULT_DATA_HEX, + DEFAULT_EVENT_DATA_HEX, + DEFAULT_MAX_FEE, + DEFAULT_MIN_VALIDATIONS, + DEFAULT_TOPIC_HEX, + INTENT_DEADLINE_OFFSET, + INTENT_HASH_LENGTH, + NONCE_LENGTH, +} from './constants' + export const LAMPORTS_PER_SOL = 1_000_000_000 +/** + * Generate a random 32-byte hex string for intent hash + */ +export function generateIntentHash(): string { + return randomHex(INTENT_HASH_LENGTH).slice(2) +} + +/** + * Generate a random 32-byte hex string for nonce + */ +export function generateNonce(): string { + return randomHex(NONCE_LENGTH).slice(2) +} + +/** + * Create a test intent with configurable parameters + */ +export async function createTestIntent( + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + options: { + intentHash?: string + nonce?: string + user?: PublicKey + deadline?: number + op?: OpType + minValidations?: number + dataHex?: string + maxFees?: TokenFee[] + eventsHex?: IntentEvent[] + isFinal?: boolean + } = {} +): Promise { + const intentHash = options.intentHash || generateIntentHash() + const nonce = options.nonce || generateNonce() + const user = options.user || Keypair.generate().publicKey + const client = solverProvider.client + const now = Number(client.getClock().unixTimestamp) + const deadline = options.deadline ?? now + INTENT_DEADLINE_OFFSET + + const params: CreateIntentParams = { + op: options.op || OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: options.minValidations ?? DEFAULT_MIN_VALIDATIONS, + dataHex: options.dataHex ?? DEFAULT_DATA_HEX, + maxFees: options.maxFees || [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: options.eventsHex || [ + { + topicHex: DEFAULT_TOPIC_HEX, + dataHex: DEFAULT_EVENT_DATA_HEX, + }, + ], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, options.isFinal ?? false) + const res = await makeTxSignAndSend(solverProvider, ix) + if (res instanceof FailedTransactionMetadata) { + throw new Error(`Failed to create intent: ${res.toString()}`) + } + return intentHash +} + /** * Helper to expect transaction errors consistently */ diff --git a/packages/svm/tests/settler.test.ts b/packages/svm/tests/settler.test.ts new file mode 100644 index 0000000..779cdf3 --- /dev/null +++ b/packages/svm/tests/settler.test.ts @@ -0,0 +1,781 @@ +/* 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 fs from 'fs' +import { LiteSVM } from 'litesvm' +import os from 'os' +import path from 'path' + +import ControllerSDK, { EntityType } from '../sdks/controller/Controller' +import SettlerSDK from '../sdks/settler/Settler' +import { OpType } from '../sdks/settler/types' +import * as ControllerIDL from '../target/idl/controller.json' +import * as SettlerIDL from '../target/idl/settler.json' +import { Settler } from '../target/types/settler' +import { + ACCOUNT_CLOSE_FEE, + DEFAULT_DATA_HEX, + DEFAULT_EVENT_DATA_HEX, + DEFAULT_MAX_FEE, + DEFAULT_MIN_VALIDATIONS, + DEFAULT_TOPIC_HEX, + DOUBLE_CLAIM_DELAY, + DOUBLE_CLAIM_DELAY_PLUS_ONE, + EMPTY_DATA_HEX, + EXPIRATION_TEST_DELAY, + EXPIRATION_TEST_DELAY_PLUS_ONE, + INTENT_DEADLINE_OFFSET, + LONG_DEADLINE, + MEDIUM_DEADLINE, + MULTIPLE_MIN_VALIDATIONS, + SHORT_DEADLINE, + STALE_CLAIM_DELAY, + STALE_CLAIM_DELAY_PLUS_ONE, + TEST_DATA_HEX_1, + TEST_DATA_HEX_2, + WARP_TIME_LONG, + WARP_TIME_SHORT, +} from './helpers/constants' +import { createTestIntent, expectTransactionError, generateIntentHash, generateNonce } from './helpers/helpers' +import { makeTxSignAndSend, warpSeconds } from './utils' + +describe('Settler Program', () => { + let client: LiteSVM + + let provider: LiteSVMProvider + let maliciousProvider: LiteSVMProvider + let solverProvider: LiteSVMProvider + + let admin: Keypair + let malicious: Keypair + let solver: Keypair + + let program: Program + + let sdk: SettlerSDK + let maliciousSdk: SettlerSDK + let solverSdk: SettlerSDK + + let controllerSdk: ControllerSDK + + before(async () => { + admin = Keypair.fromSecretKey( + Uint8Array.from(JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config', 'solana', 'id.json'), 'utf8'))) + ) + malicious = Keypair.generate() + solver = Keypair.generate() + + client = fromWorkspace(path.join(__dirname, '../')).withBuiltins().withPrecompiles().withSysvars() + + provider = new LiteSVMProvider(client, new Wallet(admin)) + maliciousProvider = new LiteSVMProvider(client, new Wallet(malicious)) + solverProvider = new LiteSVMProvider(client, new Wallet(solver)) + + program = new Program(SettlerIDL as any, provider) + + sdk = new SettlerSDK(provider) + maliciousSdk = new SettlerSDK(maliciousProvider) + solverSdk = new SettlerSDK(solverProvider) + + provider.client.airdrop(admin.publicKey, BigInt(100_000_000_000)) + provider.client.airdrop(malicious.publicKey, BigInt(100_000_000_000)) + provider.client.airdrop(solver.publicKey, BigInt(100_000_000_000)) + + // Initialize Controller and add Solver to allowlist + controllerSdk = new ControllerSDK(provider) + await makeTxSignAndSend(provider, await controllerSdk.initializeIx(admin.publicKey)) + await makeTxSignAndSend(provider, await controllerSdk.setAllowedEntityIx(EntityType.Solver, solver.publicKey)) + }) + + beforeEach(() => { + client.expireBlockhash() + }) + + describe('Settler', () => { + describe('initialize', () => { + it('cannot initialize if not deployer', async () => { + const ix = await maliciousSdk.initializeIx() + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, 'Only Deployer can call this instruction.') + }) + + it('should call initialize', async () => { + const ix = await sdk.initializeIx() + await makeTxSignAndSend(provider, ix) + + const settings = await program.account.settlerSettings.fetch(sdk.getSettlerSettingsPubkey()) + expect(settings.controllerProgram.toString()).to.be.eq(ControllerIDL.address) + }) + + it('cannot call initialize again', async () => { + const ix = await sdk.initializeIx() + const res = await makeTxSignAndSend(provider, ix) + + expectTransactionError(res, 'already in use') + }) + }) + + describe('create_intent', () => { + it('should create an intent', async () => { + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now + INTENT_DEADLINE_OFFSET + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [ + { + topicHex: DEFAULT_TOPIC_HEX, + dataHex: DEFAULT_EVENT_DATA_HEX, + }, + ], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, false) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.op).to.deep.include({ transfer: {} }) + expect(intent.user.toString()).to.be.eq(user.toString()) + expect(intent.creator.toString()).to.be.eq(solver.publicKey.toString()) + expect(Buffer.from(intent.nonce).toString('hex')).to.be.eq(nonce) + expect(intent.deadline.toNumber()).to.be.eq(deadline) + expect(intent.minValidations).to.be.eq(DEFAULT_MIN_VALIDATIONS) + expect(intent.isFinal).to.be.false + expect(Buffer.from(intent.data).toString('hex')).to.be.eq(DEFAULT_DATA_HEX) + expect(intent.maxFees.length).to.be.eq(1) + expect(intent.maxFees[0].mint.toString()).to.be.eq(params.maxFees[0].mint.toString()) + expect(intent.maxFees[0].amount.toNumber()).to.be.eq(DEFAULT_MAX_FEE) + expect(intent.events.length).to.be.eq(1) + expect(intent.validators.length).to.be.eq(0) + expect(Buffer.from(intent.events[0].topic).toString('hex')).to.be.eq(params.eventsHex[0].topicHex) + expect(Buffer.from(intent.events[0].data).toString('hex')).to.be.eq(DEFAULT_EVENT_DATA_HEX) + }) + + it('should create an intent with empty data', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + op: OpType.Swap, + minValidations: 2, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: 2000, + }, + ], + eventsHex: [], + isFinal: true, + }) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.op).to.deep.include({ swap: {} }) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq(EMPTY_DATA_HEX) + expect(intent.isFinal).to.be.true + }) + + it('cannot create an intent with empty max_fees', async () => { + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now + INTENT_DEADLINE_OFFSET + + const params = { + op: OpType.Call, + user, + nonceHex: nonce, + deadline, + minValidations: MULTIPLE_MIN_VALIDATIONS, + dataHex: TEST_DATA_HEX_1, + maxFees: [], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'No max fees provided') + }) + + it('should create an intent with empty events', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + dataHex: TEST_DATA_HEX_2, + eventsHex: [], + }) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.events.length).to.be.eq(0) + }) + + it('should create an intent with is_final true', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + dataHex: EMPTY_DATA_HEX, + eventsHex: [], + isFinal: true, + }) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.isFinal).to.be.true + }) + + it('should create an intent with is_final false', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + dataHex: EMPTY_DATA_HEX, + eventsHex: [], + isFinal: false, + }) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.isFinal).to.be.false + }) + + it('cannot create intent if not allowlisted solver', async () => { + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now + INTENT_DEADLINE_OFFSET + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await maliciousSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(maliciousProvider, ix) + expectTransactionError(res, 'AccountNotInitialized') + + const intent = client.getAccount(sdk.getIntentKey(intentHash)) + expect(intent).to.be.null + }) + + it('cannot create intent with deadline in the past', async () => { + warpSeconds(provider, WARP_TIME_LONG) + + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now - SHORT_DEADLINE + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'Deadline must be in the future') + }) + + it('cannot create intent with deadline equal to now', async () => { + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'Deadline must be in the future') + }) + + it('cannot create intent if fulfilled_intent PDA already exists', async () => { + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now + INTENT_DEADLINE_OFFSET + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + // Mock FulfilledIntent + const fulfilledIntent = sdk.getFulfilledIntentKey(intentHash) + client.setAccount(fulfilledIntent, { + executable: false, + lamports: 1002240, + owner: program.programId, + data: Buffer.from('595168911b9267f7' + '010000000000000000', 'hex'), + }) + + const ix = await solverSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'AccountNotSystemOwned') + }) + + it('cannot create intent with same intent_hash twice', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + isFinal: false, + }) + + client.expireBlockhash() + const params = { + op: OpType.Transfer, + user: Keypair.generate().publicKey, + nonceHex: generateNonce(), + deadline: Number(client.getClock().unixTimestamp) + INTENT_DEADLINE_OFFSET, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + const ix2 = await solverSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(solverProvider, ix2) + + expectTransactionError(res, 'already in use') + }) + + it('cannot create intent with invalid intent_hash', async () => { + const invalidIntentHash = '123456' + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const deadline = now + INTENT_DEADLINE_OFFSET + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: EMPTY_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + try { + const ix = await solverSdk.createIntentIx(invalidIntentHash, params, false) + await makeTxSignAndSend(solverProvider, ix) + expect.fail('Should have thrown an error') + } catch (error: any) { + expect(error.message).to.include(`Intent hash must be 32 bytes`) + } + }) + }) + + describe('extend_intent', () => { + it('should extend an intent with more data', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + isFinal: false, + }) + + const extendParams = { + moreDataHex: TEST_DATA_HEX_1, + } + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq('010203070809') + expect(intent.isFinal).to.be.false + }) + + it('should extend an intent with more max_fees', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const newMint = Keypair.generate().publicKey + const extendParams = { + moreMaxFees: [ + { + mint: newMint, + amount: 2000, + }, + ], + } + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.maxFees.length).to.be.eq(2) + expect(intent.maxFees[0].amount.toNumber()).to.be.eq(DEFAULT_MAX_FEE) + expect(intent.maxFees[1].mint.toString()).to.be.eq(newMint.toString()) + expect(intent.maxFees[1].amount.toNumber()).to.be.eq(2000) + }) + + it('should extend an intent with more events', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const newTopic = Buffer.from(Array(32).fill(2)).toString('hex') + const extendParams = { + moreEventsHex: [ + { + topicHex: newTopic, + dataHex: TEST_DATA_HEX_2, + }, + ], + } + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.events.length).to.be.eq(2) + expect(Buffer.from(intent.events[0].topic).toString('hex')).to.be.eq( + Buffer.from(Array(32).fill(1)).toString('hex') + ) + expect(Buffer.from(intent.events[1].topic).toString('hex')).to.be.eq(newTopic) + expect(Buffer.from(intent.events[1].data).toString('hex')).to.be.eq('0a0b0c') + }) + + it('should extend an intent with all optional fields', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const newMint = Keypair.generate().publicKey + const newTopic = Buffer.from(Array(32).fill(3)).toString('hex') + const extendParams = { + moreDataHex: '0d0e0f', + moreMaxFees: [ + { + mint: newMint, + amount: 3000, + }, + ], + moreEventsHex: [ + { + topicHex: newTopic, + dataHex: '101112', + }, + ], + } + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq('0102030d0e0f') + expect(intent.maxFees.length).to.be.eq(2) + expect(intent.maxFees[1].amount.toNumber()).to.be.eq(3000) + expect(intent.events.length).to.be.eq(2) + expect(Buffer.from(intent.events[1].data).toString('hex')).to.be.eq('101112') + }) + + it('should extend an intent to a large size', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + const intentKey = sdk.getIntentKey(intentHash) + + for (let i = 0; i < 100; i++) { + const ix = await solverSdk.extendIntentIx(intentHash, { moreDataHex: 'f'.repeat(100) }, false) + await makeTxSignAndSend(solverProvider, ix) + client.expireBlockhash() + } + + for (let i = 0; i < 25; i++) { + const extendParams = { + moreEventsHex: [ + { topicHex: 'e'.repeat(64), dataHex: 'beef'.repeat(100) }, + { topicHex: 'd'.repeat(64), dataHex: 'beef'.repeat(100) }, + ], + } + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + await makeTxSignAndSend(solverProvider, ix) + client.expireBlockhash() + } + + for (let i = 0; i < 19; i++) { + const extendParams = { + moreMaxFees: [ + { mint: Keypair.generate().publicKey, amount: i }, + { mint: Keypair.generate().publicKey, amount: i + 1000 }, + { mint: Keypair.generate().publicKey, amount: i + 2000 }, + ], + } + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + await makeTxSignAndSend(solverProvider, ix) + client.expireBlockhash() + } + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const intentAcc = client.getAccount(intentKey) + expect(intent.data.length).to.be.eq(3 + 5000) // Keep literal for specific test case + expect(intent.maxFees.length).to.be.eq(58) + expect(intent.events.length).to.be.eq(51) + expect(intent.isFinal).to.be.false + expect(intentAcc?.data.length).to.be.eq(19359) + }) + + it('should finalize an intent', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const extendParams = {} + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, true) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.isFinal).to.be.true + }) + + it('should extend and finalize an intent in one call', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const extendParams = { + moreDataHex: '191a1b', + } + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, true) + await makeTxSignAndSend(solverProvider, ix) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq('010203191a1b') + expect(intent.isFinal).to.be.true + }) + + it('should extend an intent multiple times', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const extendParams1 = { + moreDataHex: '1c1d1e', + } + const ix1 = await solverSdk.extendIntentIx(intentHash, extendParams1, false) + await makeTxSignAndSend(solverProvider, ix1) + + const extendParams2 = { + moreDataHex: '1f2021', + } + const ix2 = await solverSdk.extendIntentIx(intentHash, extendParams2, false) + await makeTxSignAndSend(solverProvider, ix2) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq('0102031c1d1e1f2021') + expect(intent.isFinal).to.be.false + }) + + it('cannot extend intent if not intent creator', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + + const extendParams = { + moreDataHex: '222324', + } + + const ix = await maliciousSdk.extendIntentIx(intentHash, extendParams, false) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, `Signer must be intent creator`) + }) + + it('cannot extend non-existent intent', async () => { + const intentHash = generateIntentHash() + + const extendParams = { + moreDataHex: '252627', + } + + const ix = await sdk.extendIntentIx(intentHash, extendParams, false) + const res = await makeTxSignAndSend(provider, ix) + + expectTransactionError(res, `AccountNotInitialized`) + }) + + it('cannot extend intent if already finalized', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: true }) + + const extendParams = { + moreDataHex: '28292a', + } + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Intent is already final`) + }) + + it('cannot finalize already finalized intent', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: true }) + + const extendParams = {} + + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, true) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Intent is already final`) + }) + }) + + describe('claim_stale_intent', () => { + const createTestIntentWithDeadline = async (deadline: number, isFinal = false): Promise => { + return createTestIntent(solverSdk, solverProvider, { deadline, isFinal }) + } + + it('should claim stale intent', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + STALE_CLAIM_DELAY + const intentHash = await createTestIntentWithDeadline(deadline, false) + + const intentBefore = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intentBefore).to.not.be.null + + warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) + + const intentBalanceBefore = Number(provider.client.getBalance(sdk.getIntentKey(intentHash))) || 0 + const intentCreatorBalanceBefore = Number(provider.client.getBalance(intentBefore.creator)) || 0 + + const ix = await solverSdk.claimStaleIntentIx(intentHash) + await makeTxSignAndSend(solverProvider, ix) + + const intentBalanceAfter = Number(provider.client.getBalance(sdk.getIntentKey(intentHash))) || 0 + const intentCreatorBalanceAfter = Number(provider.client.getBalance(intentBefore.creator)) || 0 + + try { + await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect.fail('Intent account should be closed') + } catch (error: any) { + expect(error.message).to.include(`Account does not exist`) + } + + expect(intentCreatorBalanceAfter).to.be.eq(intentCreatorBalanceBefore + intentBalanceBefore - ACCOUNT_CLOSE_FEE) + expect(intentBalanceAfter).to.be.eq(0) + }) + + it('cannot claim intent if deadline has not passed', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + LONG_DEADLINE + const intentHash = await createTestIntentWithDeadline(deadline, false) + + warpSeconds(provider, WARP_TIME_SHORT) + + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'Intent not yet expired') + }) + + it('cannot claim intent if deadline equals now', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + MEDIUM_DEADLINE + const intentHash = await createTestIntentWithDeadline(deadline, false) + + warpSeconds(provider, MEDIUM_DEADLINE) + + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'Intent not yet expired') + }) + + it('cannot claim stale intent if not intent creator', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + EXPIRATION_TEST_DELAY + const intentHash = await createTestIntentWithDeadline(deadline, false) + + warpSeconds(provider, EXPIRATION_TEST_DELAY_PLUS_ONE) + + const ix = await maliciousSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, `Signer must be intent creator`) + }) + + it('cannot claim non-existent intent', async () => { + const intentHash = generateIntentHash() + + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `AccountNotInitialized`) + }) + + it('cannot claim intent twice', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + DOUBLE_CLAIM_DELAY + const intentHash = await createTestIntentWithDeadline(deadline, false) + + warpSeconds(provider, DOUBLE_CLAIM_DELAY_PLUS_ONE) + + const ix = await solverSdk.claimStaleIntentIx(intentHash) + await makeTxSignAndSend(solverProvider, ix) + + client.expireBlockhash() + const ix2 = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix2) + + const errorMsg = res.toString() + expect(errorMsg.includes(`AccountNotInitialized`)).to.be.true + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 9081310..e3c33a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -526,6 +526,11 @@ dependencies: "@noble/hashes" "1.7.2" +"@noble/ed25519@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-3.0.0.tgz#720d4cdb6b5f632e29164a7e9d5cdfeb82a7ac86" + integrity sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg== + "@noble/hashes@1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" @@ -4009,7 +4014,16 @@ string-format@^2.0.0: resolved "https://registry.yarnpkg.com/string-format/-/string-format-2.0.0.tgz#f2df2e7097440d3b65de31b6d40d54c96eaffb9b" integrity sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4066,7 +4080,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4504,7 +4525,16 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 6683e31fe1e4432f672059e0a62dbc092da910a2 Mon Sep 17 00:00:00 2001 From: GuidoDipietro Date: Tue, 6 Jan 2026 13:20:39 -0300 Subject: [PATCH 2/3] Remove expect from before --- packages/svm/tests/controller.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/svm/tests/controller.test.ts b/packages/svm/tests/controller.test.ts index 747d064..9288c40 100644 --- a/packages/svm/tests/controller.test.ts +++ b/packages/svm/tests/controller.test.ts @@ -233,9 +233,6 @@ describe('Controller', () => { before('change admin for next tests', async () => { const ix = await adminSdk.setAdmin(otherAdmin.publicKey) await makeTxSignAndSend(adminProvider, ix) - - const settings = await program.account.controllerSettings.fetch(adminSdk.getControllerSettingsPubkey()) - expect(settings.admin.toString()).to.be.eq(otherAdmin.publicKey.toString()) }) context('when the admin was changed', async () => { From 3f2930b5702b90393068a2d97486efdb890c85f7 Mon Sep 17 00:00:00 2001 From: GuidoDipietro Date: Tue, 6 Jan 2026 13:29:00 -0300 Subject: [PATCH 3/3] Update controller.json IDL to correct one outside target/ --- packages/svm/idls/controller.json | 168 ++++++++++++++++-------------- 1 file changed, 92 insertions(+), 76 deletions(-) diff --git a/packages/svm/idls/controller.json b/packages/svm/idls/controller.json index e4494a5..0e14ac3 100644 --- a/packages/svm/idls/controller.json +++ b/packages/svm/idls/controller.json @@ -25,7 +25,7 @@ "writable": true, "signer": true, "relations": [ - "global_settings" + "controller_settings" ] }, { @@ -33,18 +33,22 @@ "writable": true }, { - "name": "global_settings", + "name": "controller_settings", "pda": { "seeds": [ { "kind": "const", "value": [ - 103, - 108, + 99, + 111, + 110, + 116, + 114, 111, - 98, - 97, 108, + 108, + 101, + 114, 45, 115, 101, @@ -80,43 +84,41 @@ ] }, { - "name": "create_entity_registry", + "name": "initialize", "discriminator": [ - 59, - 253, - 73, - 126, - 171, - 188, - 172, - 156 + 175, + 175, + 109, + 31, + 13, + 152, + 155, + 237 ], "accounts": [ { - "name": "admin", + "name": "deployer", "writable": true, - "signer": true, - "relations": [ - "global_settings" - ] - }, - { - "name": "entity_registry", - "writable": true + "signer": true }, { - "name": "global_settings", + "name": "controller_settings", + "writable": true, "pda": { "seeds": [ { "kind": "const", "value": [ - 103, - 108, + 99, 111, - 98, - 97, + 110, + 116, + 114, + 111, + 108, 108, + 101, + 114, 45, 115, 101, @@ -138,51 +140,50 @@ ], "args": [ { - "name": "entity_type", - "type": { - "defined": { - "name": "EntityType" - } - } - }, - { - "name": "entity_pubkey", + "name": "admin", "type": "pubkey" } ] }, { - "name": "initialize", + "name": "set_admin", "discriminator": [ - 175, - 175, - 109, - 31, - 13, - 152, - 155, - 237 + 251, + 163, + 0, + 52, + 91, + 194, + 187, + 92 ], "accounts": [ { - "name": "deployer", + "name": "admin", "writable": true, - "signer": true + "signer": true, + "relations": [ + "controller_settings" + ] }, { - "name": "global_settings", + "name": "controller_settings", "writable": true, "pda": { "seeds": [ { "kind": "const", "value": [ - 103, - 108, + 99, + 111, + 110, + 116, + 114, 111, - 98, - 97, 108, + 108, + 101, + 114, 45, 115, 101, @@ -196,30 +197,26 @@ } ] } - }, - { - "name": "system_program", - "address": "11111111111111111111111111111111" } ], "args": [ { - "name": "admin", + "name": "new_admin", "type": "pubkey" } ] }, { - "name": "set_admin", + "name": "set_allowed_entity", "discriminator": [ - 251, - 163, - 0, - 52, - 91, - 194, - 187, - 92 + 140, + 26, + 199, + 225, + 147, + 47, + 14, + 29 ], "accounts": [ { @@ -227,23 +224,30 @@ "writable": true, "signer": true, "relations": [ - "global_settings" + "controller_settings" ] }, { - "name": "global_settings", - "writable": true, + "name": "entity_registry", + "writable": true + }, + { + "name": "controller_settings", "pda": { "seeds": [ { "kind": "const", "value": [ - 103, - 108, + 99, 111, - 98, - 97, + 110, + 116, + 114, + 111, + 108, 108, + 101, + 114, 45, 115, 101, @@ -257,11 +261,23 @@ } ] } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" } ], "args": [ { - "name": "new_admin", + "name": "entity_type", + "type": { + "defined": { + "name": "EntityType" + } + } + }, + { + "name": "entity_pubkey", "type": "pubkey" } ]