diff --git a/packages/svm/programs/settler/src/instructions/add_instructions_to_proposal.rs b/packages/svm/programs/settler/src/instructions/add_instructions_to_proposal.rs new file mode 100644 index 0000000..9ba9ed3 --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/add_instructions_to_proposal.rs @@ -0,0 +1,45 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::SettlerError, + state::{Proposal, ProposalInstruction}, +}; + +#[derive(Accounts)] +#[instruction(more_instructions: Vec)] +pub struct AddInstructionsToProposal<'info> { + #[account(mut)] + pub creator: Signer<'info>, + + #[account( + mut, + realloc = Proposal::extended_size(proposal.to_account_info().data_len(), &more_instructions)?, + realloc::payer = creator, + realloc::zero = true, + has_one = creator @ SettlerError::IncorrectProposalCreator + )] + // Any proposal + pub proposal: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn add_instructions_to_proposal( + ctx: Context, + more_instructions: Vec, + finalize: bool, +) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + let proposal = &mut ctx.accounts.proposal; + + require!(proposal.deadline > now, SettlerError::ProposalIsExpired); + require!(!proposal.is_final, SettlerError::ProposalIsFinal); + + proposal.instructions.extend_from_slice(&more_instructions); + + if finalize { + proposal.is_final = true; + } + + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs b/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs index d5b397e..9a3f6f1 100644 --- a/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs +++ b/packages/svm/programs/settler/src/instructions/claim_stale_intent.rs @@ -11,17 +11,11 @@ pub struct ClaimStaleIntent<'info> { mut, close = creator, has_one = creator @ SettlerError::IncorrectIntentCreator, + constraint = Clock::get()?.unix_timestamp as u64 > intent.deadline @ SettlerError::IntentNotYetExpired )] 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 - ); - +pub fn claim_stale_intent(_ctx: Context) -> Result<()> { Ok(()) } diff --git a/packages/svm/programs/settler/src/instructions/claim_stale_proposal.rs b/packages/svm/programs/settler/src/instructions/claim_stale_proposal.rs new file mode 100644 index 0000000..3ec7674 --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/claim_stale_proposal.rs @@ -0,0 +1,21 @@ +use anchor_lang::prelude::*; + +use crate::{errors::SettlerError, state::Proposal}; + +#[derive(Accounts)] +pub struct ClaimStaleProposal<'info> { + #[account(mut)] + pub creator: Signer<'info>, + + #[account( + mut, + close = creator, + has_one = creator @ SettlerError::IncorrectProposalCreator, + constraint = Clock::get()?.unix_timestamp as u64 > proposal.deadline @ SettlerError::ProposalNotYetExpired + )] + pub proposal: Box>, +} + +pub fn claim_stale_proposal(_ctx: Context) -> Result<()> { + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/create_proposal.rs b/packages/svm/programs/settler/src/instructions/create_proposal.rs new file mode 100644 index 0000000..598df6a --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/create_proposal.rs @@ -0,0 +1,95 @@ +use anchor_lang::prelude::*; + +use crate::{ + controller::{self, accounts::EntityRegistry, types::EntityType}, + errors::SettlerError, + state::{Intent, Proposal, ProposalInstruction}, + types::TokenFee, +}; + +#[derive(Accounts)] +#[instruction(instructions: Vec, fees: Vec,)] +pub struct CreateProposal<'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>, + + /// Any intent + pub intent: Box>, + + #[account( + seeds = [b"fulfilled-intent", intent.hash.as_ref()], + bump + )] + /// This PDA must be uninitialized (checked by SystemAccount type) + pub fulfilled_intent: SystemAccount<'info>, + + #[account( + init, + seeds = [b"proposal", intent.key().as_ref(), solver.key().as_ref()], + bump, + payer = solver, + space = Proposal::total_size(&instructions, fees.len())? + )] + pub proposal: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn create_proposal( + ctx: Context, + instructions: Vec, + fees: Vec, + deadline: u64, + is_final: bool, +) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + let intent = &ctx.accounts.intent; + + require!(deadline > now, SettlerError::DeadlineIsInThePast); + require!(intent.deadline > now, SettlerError::IntentIsExpired); + require!( + deadline <= intent.deadline, + SettlerError::ProposalDeadlineExceedsIntentDeadline + ); + require!( + intent.validators.len() >= intent.min_validations as usize, + SettlerError::InsufficientIntentValidations + ); + require!(intent.is_final, SettlerError::IntentIsNotFinal); + require!( + fees.len() == intent.max_fees.len(), + SettlerError::InvalidFeeMint + ); + + fees.iter() + .zip(&intent.max_fees) + .try_for_each(|(fee, max_fee)| { + require_keys_eq!(fee.mint, max_fee.mint, SettlerError::InvalidFeeMint); + require_gte!( + max_fee.amount, + fee.amount, + SettlerError::FeeAmountExceedsMaxFee + ); + Ok(()) + })?; + + let proposal = &mut ctx.accounts.proposal; + + proposal.intent = intent.key(); + proposal.creator = ctx.accounts.solver.key(); + proposal.deadline = deadline; + proposal.is_final = is_final; + proposal.is_signed = false; + proposal.instructions = instructions; + proposal.fees = fees; + proposal.bump = ctx.bumps.proposal; + + Ok(()) +} diff --git a/packages/svm/programs/settler/src/instructions/execute_proposal.rs b/packages/svm/programs/settler/src/instructions/execute_proposal.rs new file mode 100644 index 0000000..ac7ab12 --- /dev/null +++ b/packages/svm/programs/settler/src/instructions/execute_proposal.rs @@ -0,0 +1,84 @@ +use anchor_lang::prelude::*; + +use crate::{ + controller::{self, accounts::EntityRegistry, types::EntityType}, + errors::SettlerError, + state::{FulfilledIntent, Intent, Proposal}, + types::IntentEvent, +}; + +#[derive(Accounts)] +pub struct ExecuteProposal<'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>, + + /// CHECK: account defined in proposal + #[account(mut)] + pub proposal_creator: UncheckedAccount<'info>, + + #[account( + mut, + has_one = intent @ SettlerError::IncorrectIntentForProposal, + constraint = proposal.creator == proposal_creator.key() @ SettlerError::IncorrectProposalCreator, + constraint = proposal.is_signed @ SettlerError::ProposalIsNotSigned, + close = proposal_creator + )] + pub proposal: Box>, + + /// CHECK: account defined in intent + #[account(mut)] + pub intent_creator: UncheckedAccount<'info>, + + #[account( + mut, + constraint = intent.creator == intent_creator.key() @ SettlerError::IncorrectIntentCreator, + close = intent_creator + )] + pub intent: Box>, + + #[account( + init, + seeds = [b"fulfilled-intent", intent.hash.as_ref()], + bump, + space = 8 + FulfilledIntent::INIT_SPACE, + payer = solver + )] + pub fulfilled_intent: Box>, + + pub system_program: Program<'info, System>, +} + +pub fn execute_proposal(ctx: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp as u64; + let proposal = &ctx.accounts.proposal; + let intent = &ctx.accounts.intent; + + require!(proposal.deadline > now, SettlerError::ProposalIsExpired); + + // TODO: Execute proposal + + // TODO: Validate execution + + // TODO: Emit events + intent.events.iter().for_each(|event| { + emit!(IntentEventEvent { + event: event.clone() + }) + }); + + // TODO: Pay fees to Solver + + Ok(()) +} + +#[event] +pub struct IntentEventEvent { + event: IntentEvent, +} diff --git a/packages/svm/programs/settler/src/instructions/mod.rs b/packages/svm/programs/settler/src/instructions/mod.rs index a7aa8cc..2a7938f 100644 --- a/packages/svm/programs/settler/src/instructions/mod.rs +++ b/packages/svm/programs/settler/src/instructions/mod.rs @@ -1,9 +1,17 @@ +pub mod add_instructions_to_proposal; pub mod claim_stale_intent; +pub mod claim_stale_proposal; pub mod create_intent; +pub mod create_proposal; +pub mod execute_proposal; pub mod extend_intent; pub mod initialize; +pub use add_instructions_to_proposal::*; pub use claim_stale_intent::*; +pub use claim_stale_proposal::*; pub use create_intent::*; +pub use create_proposal::*; +pub use execute_proposal::*; 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 c9052c3..b1ca22a 100644 --- a/packages/svm/programs/settler/src/lib.rs +++ b/packages/svm/programs/settler/src/lib.rs @@ -10,16 +10,30 @@ pub mod state; pub mod types; pub mod utils; -use crate::{instructions::*, types::*}; +use crate::{instructions::*, state::*, types::*}; #[program] pub mod settler { use super::*; + pub fn add_instructions_to_proposal( + ctx: Context, + more_instructions: Vec, + finalize: bool, + ) -> Result<()> { + instructions::add_instructions_to_proposal(ctx, more_instructions, finalize) + } + pub fn claim_stale_intent(ctx: Context) -> Result<()> { instructions::claim_stale_intent(ctx) } + pub fn claim_stale_proposal<'info>( + ctx: Context<'_, '_, 'info, 'info, ClaimStaleProposal<'info>>, + ) -> Result<()> { + instructions::claim_stale_proposal(ctx) + } + pub fn create_intent( ctx: Context, intent_hash: [u8; 32], @@ -48,6 +62,20 @@ pub mod settler { ) } + pub fn create_proposal( + ctx: Context, + instructions: Vec, + fees: Vec, + deadline: u64, + is_final: bool, + ) -> Result<()> { + instructions::create_proposal(ctx, instructions, fees, deadline, is_final) + } + + pub fn execute_proposal(ctx: Context) -> Result<()> { + instructions::execute_proposal(ctx) + } + pub fn extend_intent( ctx: Context, more_data: Option>, diff --git a/packages/svm/programs/settler/src/state/mod.rs b/packages/svm/programs/settler/src/state/mod.rs index a192198..fb0b3ba 100644 --- a/packages/svm/programs/settler/src/state/mod.rs +++ b/packages/svm/programs/settler/src/state/mod.rs @@ -1,7 +1,9 @@ pub mod fulfilled_intent; pub mod intent; +pub mod proposal; pub mod settler_settings; pub use fulfilled_intent::*; pub use intent::*; +pub use proposal::*; pub use settler_settings::*; diff --git a/packages/svm/programs/settler/src/state/proposal.rs b/packages/svm/programs/settler/src/state/proposal.rs new file mode 100644 index 0000000..d1f3119 --- /dev/null +++ b/packages/svm/programs/settler/src/state/proposal.rs @@ -0,0 +1,81 @@ +use anchor_lang::prelude::*; + +use crate::{ + types::TokenFee, + utils::{add, mul, sub}, +}; + +#[account] +pub struct Proposal { + pub intent: Pubkey, + pub creator: Pubkey, + pub deadline: u64, + pub is_final: bool, + pub is_signed: bool, + pub instructions: Vec, + pub fees: Vec, + pub bump: u8, +} + +impl Proposal { + /// Doesn't take into account size of variable fields + pub const BASE_LEN: usize = + 32 + // intent + 32 + // creator + 8 + // deadline + 1 + // is_final + 1 + // is_signed + 1 // bump + ; + + pub fn total_size(instructions: &Vec, fees_len: usize) -> Result { + let size = add(8, Proposal::BASE_LEN)?; + let size = add(size, Proposal::instructions_size(instructions)?)?; + let size = add(size, Proposal::fees_size(fees_len)?)?; + Ok(size) + } + + pub fn instructions_size(instructions: &Vec) -> Result { + let sum = instructions + .iter() + .try_fold(0usize, |acc, ix| add(acc, ix.size()))?; + add(4, sum) + } + + pub fn fees_size(len: usize) -> Result { + add(4, mul(TokenFee::INIT_SPACE, len)?) + } + + pub fn extended_size( + size: usize, + more_instructions: &Vec, + ) -> Result { + sub( + add(size, Proposal::instructions_size(more_instructions)?)?, + 4, + ) + } +} + +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub struct ProposalInstruction { + pub program_id: Pubkey, + pub accounts: Vec, + pub data: Vec, +} + +impl ProposalInstruction { + pub fn size(&self) -> usize { + let accounts_size = 4 + self.accounts.len() * (32 + 1 + 1); + let data_size = 4 + self.data.len(); + + 32 + accounts_size + data_size + } +} + +#[derive(Clone, AnchorSerialize, AnchorDeserialize)] +pub struct ProposalInstructionAccountMeta { + pub pubkey: Pubkey, + pub is_signer: bool, + pub is_writable: bool, +} diff --git a/packages/svm/sdks/settler/Settler.ts b/packages/svm/sdks/settler/Settler.ts index 8bea75d..48d07ee 100644 --- a/packages/svm/sdks/settler/Settler.ts +++ b/packages/svm/sdks/settler/Settler.ts @@ -4,7 +4,15 @@ 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' +import { + CreateIntentParams, + ExtendIntentParams, + IntentEvent, + OpType, + ProposalInstruction, + ProposalInstructionAccountMeta, + TokenFee, +} from './types' type TokenFeeAnchor = { mint: web3.PublicKey @@ -16,6 +24,12 @@ type IntentEventAnchor = { data: Buffer } +type ProposalInstructionAnchor = { + programId: web3.PublicKey + accounts: ProposalInstructionAccountMeta[] + data: Buffer +} + export default class SettlerSDK { protected program: Program @@ -97,6 +111,64 @@ export default class SettlerSDK { return ix } + async createProposalIx( + intentHashHex: string, + instructions: ProposalInstruction[], + fees: TokenFee[], + deadline: number, + isFinal = true + ): Promise { + const parsedInstructions = this.parseProposalInstructions(instructions) + const parsedFees = this.parseTokenFees(fees) + + const ix = await this.program.methods + .createProposal(parsedInstructions, parsedFees, new BN(deadline), isFinal) + .accountsPartial({ + solver: this.getSignerKey(), + solverRegistry: this.getEntityRegistryPubkey(EntityType.Solver, this.getSignerKey()), + intent: this.getIntentKey(intentHashHex), + fulfilledIntent: this.getFulfilledIntentKey(intentHashHex), + }) + .instruction() + + return ix + } + + async addInstructionsToProposalIx( + intentHashHex: string, + moreInstructions: ProposalInstruction[], + finalize = true, + solverPubkey?: web3.PublicKey + ): Promise { + const parsedInstructions = this.parseProposalInstructions(moreInstructions) + const solver = solverPubkey || this.getSignerKey() + + const ix = await this.program.methods + .addInstructionsToProposal(parsedInstructions, finalize) + .accountsPartial({ + creator: this.getSignerKey(), + proposal: this.getProposalKey(intentHashHex, solver), + }) + .instruction() + + return ix + } + + async claimStaleProposalIx( + intentHashHex: string, + solverPubkey?: web3.PublicKey + ): Promise { + const ix = await this.program.methods + .claimStaleProposal() + .accountsPartial({ + creator: this.getSignerKey(), + proposal: this.getProposalKey(intentHashHex, solverPubkey), + }) + .instruction() + + return ix + } + getSettlerSettingsPubkey(): web3.PublicKey { return web3.PublicKey.findProgramAddressSync([Buffer.from('settler-settings')], this.program.programId)[0] } @@ -118,6 +190,19 @@ export default class SettlerSDK { )[0] } + getProposalKey(intentHashHex: string, solverPubkey?: web3.PublicKey): web3.PublicKey { + const intentHash = Buffer.from(intentHashHex, 'hex') + if (intentHash.length != 32) throw new Error(`Intent hash must be 32 bytes: ${intentHashHex}`) + + const intentKey = this.getIntentKey(intentHashHex) + const solver = solverPubkey || this.getSignerKey() + + return web3.PublicKey.findProgramAddressSync( + [Buffer.from('proposal'), intentKey.toBuffer(), solver.toBuffer()], + 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()], @@ -165,4 +250,11 @@ export default class SettlerSDK { amount: new BN(tokenFee.amount), })) } + + private parseProposalInstructions(instructions: ProposalInstruction[]): ProposalInstructionAnchor[] { + return instructions.map((instruction) => ({ + ...instruction, + data: typeof instruction.data === 'string' ? Buffer.from(instruction.data, 'hex') : instruction.data, + })) + } } diff --git a/packages/svm/sdks/settler/types.ts b/packages/svm/sdks/settler/types.ts index 92d487a..8c9ad85 100644 --- a/packages/svm/sdks/settler/types.ts +++ b/packages/svm/sdks/settler/types.ts @@ -34,3 +34,15 @@ export type ExtendIntentParams = { moreMaxFees?: TokenFee[] moreEventsHex?: IntentEvent[] } + +export type ProposalInstructionAccountMeta = { + pubkey: web3.PublicKey + isSigner: boolean + isWritable: boolean +} + +export type ProposalInstruction = { + programId: web3.PublicKey + accounts: ProposalInstructionAccountMeta[] + data: Buffer | string +} diff --git a/packages/svm/tests/helpers/helpers.ts b/packages/svm/tests/helpers/helpers.ts index 168fbdd..81d3ff4 100644 --- a/packages/svm/tests/helpers/helpers.ts +++ b/packages/svm/tests/helpers/helpers.ts @@ -1,12 +1,15 @@ -import { web3 } from '@coral-xyz/anchor' +import { Program, 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 { FailedTransactionMetadata, LiteSVM, TransactionMetadata } from 'litesvm' +import ControllerSDK, { EntityType } from '../../sdks/controller/Controller' import SettlerSDK from '../../sdks/settler/Settler' -import { CreateIntentParams, IntentEvent, OpType, TokenFee } from '../../sdks/settler/types' +import { CreateIntentParams, IntentEvent, OpType, ProposalInstruction, TokenFee } from '../../sdks/settler/types' +import * as SettlerIDL from '../../target/idl/settler.json' +import { Settler } from '../../target/types/settler' import { makeTxSignAndSend } from '../utils' import { DEFAULT_DATA_HEX, @@ -90,6 +93,142 @@ export async function createTestIntent( return intentHash } +/** + * Add mock validators to an intent account + */ +export async function addValidatorsToIntent( + intentHash: string, + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + client: LiteSVM, + numValidators: number, + program?: Program +): Promise { + const intentKey = solverSdk.getIntentKey(intentHash) + const programInstance = program || new Program(SettlerIDL, solverProvider) + + // Fetch and deserialize the intent account + const intent = await programInstance.account.intent.fetch(intentKey) + + // Generate validators + const validators: PublicKey[] = [] + for (let i = 0; i < numValidators; i++) { + validators.push(Keypair.generate().publicKey) + } + + // Modify the intent to add validators + const modifiedIntent = { + ...intent, + validators, + } + + // Serialize the modified intent back to account data + const serializedData = await programInstance.coder.accounts.encode('intent', modifiedIntent) + + // Update the account data + const intentAccount = client.getAccount(intentKey) + if (intentAccount) { + client.setAccount(intentKey, { + ...intentAccount, + data: serializedData, + }) + } +} + +/** + * Create a validated intent (with validators added to meet min_validations requirement) + */ +export async function createValidatedIntent( + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + client: LiteSVM, + options: { + intentHash?: string + minValidations?: number + isFinal?: boolean + deadline?: number + program?: Program + } = {} +): Promise { + const intentHash = await createTestIntent(solverSdk, solverProvider, { + ...options, + isFinal: options.isFinal ?? true, + }) + + // Add validators to meet min_validations requirement + const minValidations = options.minValidations ?? DEFAULT_MIN_VALIDATIONS + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, minValidations, options.program) + + return intentHash +} + +/** + * Create a finalized proposal + */ +export async function createFinalizedProposal( + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + client: LiteSVM, + program: Program, + options: { + intentHash?: string + deadline?: number + instructions?: ProposalInstruction[] + fees?: TokenFee[] + } = {} +): Promise<{ intentHash: string; proposalKey: PublicKey }> { + const intentHash = + options.intentHash || (await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true })) + const intent = await program.account.intent.fetch(solverSdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const proposalDeadline = options.deadline ?? now + 1800 + + const instructions = options.instructions || [ + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + data: 'deadbeef', + }, + ] + + const fees = + options.fees || + (intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) as TokenFee[]) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, proposalDeadline, true) + const res = await makeTxSignAndSend(solverProvider, ix) + if (res instanceof FailedTransactionMetadata) { + throw new Error(`Failed to create proposal: ${res.toString()}`) + } + + const proposalKey = solverSdk.getProposalKey(intentHash, solverProvider.wallet.publicKey) + return { intentHash, proposalKey } +} + +/** + * Creates an allowlisted entity (validator, axia, or solver) + */ +export async function createAllowlistedEntity( + controllerSdk: ControllerSDK, + provider: LiteSVMProvider, + entityType: EntityType, + entityKeypair?: Keypair +): Promise { + const entity = entityKeypair || Keypair.generate() + const allowlistIx = await controllerSdk.setAllowedEntityIx(entityType, entity.publicKey) + await makeTxSignAndSend(provider, allowlistIx) + return entity +} + /** * Helper to expect transaction errors consistently */ diff --git a/packages/svm/tests/settler.test.ts b/packages/svm/tests/settler.test.ts index 779cdf3..ad86680 100644 --- a/packages/svm/tests/settler.test.ts +++ b/packages/svm/tests/settler.test.ts @@ -6,7 +6,7 @@ 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 { FailedTransactionMetadata, LiteSVM } from 'litesvm' import os from 'os' import path from 'path' @@ -21,6 +21,8 @@ import { DEFAULT_DATA_HEX, DEFAULT_EVENT_DATA_HEX, DEFAULT_MAX_FEE, + DEFAULT_MAX_FEE_EXCEED, + DEFAULT_MAX_FEE_HALF, DEFAULT_MIN_VALIDATIONS, DEFAULT_TOPIC_HEX, DOUBLE_CLAIM_DELAY, @@ -32,15 +34,24 @@ import { LONG_DEADLINE, MEDIUM_DEADLINE, MULTIPLE_MIN_VALIDATIONS, + PROPOSAL_DEADLINE_OFFSET, SHORT_DEADLINE, STALE_CLAIM_DELAY, STALE_CLAIM_DELAY_PLUS_ONE, TEST_DATA_HEX_1, TEST_DATA_HEX_2, + TEST_DATA_HEX_3, WARP_TIME_LONG, WARP_TIME_SHORT, } from './helpers/constants' -import { createTestIntent, expectTransactionError, generateIntentHash, generateNonce } from './helpers/helpers' +import { + addValidatorsToIntent, + createTestIntent, + createValidatedIntent, + expectTransactionError, + generateIntentHash, + generateNonce, +} from './helpers/helpers' import { makeTxSignAndSend, warpSeconds } from './utils' describe('Settler Program', () => { @@ -777,5 +788,1034 @@ describe('Settler Program', () => { expect(errorMsg.includes(`AccountNotInitialized`)).to.be.true }) }) + + describe('create_proposal', () => { + it('should create a proposal', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + if (res instanceof FailedTransactionMetadata) { + throw new Error(`Failed to create proposal: ${res.toString()}`) + } + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.intent.toString()).to.be.eq(sdk.getIntentKey(intentHash).toString()) + expect(proposal.creator.toString()).to.be.eq(solver.publicKey.toString()) + expect(proposal.deadline.toNumber()).to.be.eq(deadline) + expect(proposal.isFinal).to.be.true + expect(proposal.instructions.length).to.be.eq(1) + expect(proposal.instructions[0].programId.toString()).to.be.eq(instructions[0].programId.toString()) + expect(Buffer.from(proposal.instructions[0].data).toString('hex')).to.be.eq('deadbeef') + expect(proposal.instructions[0].accounts.length).to.be.eq(1) + expect(proposal.instructions[0].accounts[0].pubkey.toString()).to.be.eq( + instructions[0].accounts[0].pubkey.toString() + ) + expect(proposal.instructions[0].accounts[0].isSigner).to.be.eq(false) + expect(proposal.instructions[0].accounts[0].isWritable).to.be.eq(true) + }) + + it('should create a proposal with multiple instructions', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + data: '010203', + }, + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: true, + isWritable: false, + }, + ], + data: '040506', + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + if (res instanceof FailedTransactionMetadata) { + throw new Error(`Failed to create proposal: ${res.toString()}`) + } + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.instructions.length).to.be.eq(2) + expect(Buffer.from(proposal.instructions[0].data).toString('hex')).to.be.eq('010203') + expect(Buffer.from(proposal.instructions[1].data).toString('hex')).to.be.eq('040506') + expect(proposal.isFinal).to.be.true + expect(proposal.instructions[0].accounts.length).to.be.eq(1) + expect(proposal.instructions[0].accounts[0].pubkey.toString()).to.be.eq( + instructions[0].accounts[0].pubkey.toString() + ) + expect(proposal.instructions[0].accounts[0].isSigner).to.be.eq(false) + expect(proposal.instructions[0].accounts[0].isWritable).to.be.eq(true) + expect(proposal.instructions[1].accounts.length).to.be.eq(1) + expect(proposal.instructions[1].accounts[0].pubkey.toString()).to.be.eq( + instructions[1].accounts[0].pubkey.toString() + ) + expect(proposal.instructions[1].accounts[0].isSigner).to.be.eq(true) + expect(proposal.instructions[1].accounts[0].isWritable).to.be.eq(false) + }) + + it('should create a proposal with empty instructions', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions: any[] = [] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + if (res instanceof FailedTransactionMetadata) { + throw new Error(`Failed to create proposal: ${res.toString()}`) + } + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.instructions.length).to.be.eq(0) + }) + + it('cannot create proposal if not whitelisted solver', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await maliciousSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(maliciousProvider, ix) + expectTransactionError(res, 'AccountNotInitialized') + }) + + it('cannot create proposal with deadline in the past', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now - SHORT_DEADLINE + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Deadline must be in the future`) + }) + + it('cannot create proposal with deadline equal to now', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Deadline must be in the future`) + }) + + it('cannot create proposal if intent deadline has passed', async () => { + const intentHash = generateIntentHash() + const nonce = generateNonce() + const user = Keypair.generate().publicKey + const now = Number(client.getClock().unixTimestamp) + const intentDeadline = now + SHORT_DEADLINE + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline: intentDeadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params) + await makeTxSignAndSend(solverProvider, ix) + + // Add validators + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, 1, program) + + warpSeconds(provider, 101) + + const proposalDeadline = now + 200 + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const ix2 = await solverSdk.createProposalIx(intentHash, instructions, [], proposalDeadline) + const res = await makeTxSignAndSend(solverProvider, ix2) + + expectTransactionError(res, `Intent has already expired`) + }) + + it('cannot create proposal if proposal deadline exceeds intent deadline', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intentDeadline = Number((await program.account.intent.fetch(sdk.getIntentKey(intentHash))).deadline) + const proposalDeadline = intentDeadline + SHORT_DEADLINE + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const ix = await solverSdk.createProposalIx(intentHash, instructions, [], proposalDeadline) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Proposal deadline can't be after the Intent's deadline`) + }) + + it('cannot create proposal if intent has insufficient validations', 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: 2, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint: Keypair.generate().publicKey, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params) + await makeTxSignAndSend(solverProvider, ix) + + // Add validators to 1 (less than min_validations of 2) + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, 1, program) + + const proposalDeadline = now + PROPOSAL_DEADLINE_OFFSET + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const ix2 = await solverSdk.createProposalIx(intentHash, instructions, [], proposalDeadline) + const res = await makeTxSignAndSend(solverProvider, ix2) + + expectTransactionError(res, `Intent has insufficient validations`) + }) + + it('cannot create proposal if intent is not final', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: false }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Intent is not final`) + }) + + it('cannot create proposal if fulfilled_intent PDA already exists', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + // Mock FulfilledIntent + const fulfilledIntent = sdk.getFulfilledIntentKey(intentHash) + client.setAccount(fulfilledIntent, { + executable: false, + lamports: 1002240, + owner: program.programId, + data: Buffer.from('595168911b9267f7' + '010000000000000000', 'hex'), + }) + + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError( + res, + `AnchorError caused by account: fulfilled_intent. Error Code: AccountNotSystemOwned. Error Number: 3011. Error Message: The given account is not owned by the system program` + ) + }) + + it('cannot create proposal with same intent_hash and solver twice', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + await makeTxSignAndSend(solverProvider, ix) + + client.expireBlockhash() + const ix2 = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline) + const res = await makeTxSignAndSend(solverProvider, ix2) + + expectTransactionError(res, `already in use`) + }) + + it('cannot create proposal for non-existent intent', async () => { + const intentHash = generateIntentHash() + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const ix = await solverSdk.createProposalIx(intentHash, instructions, [], deadline) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `AccountNotInitialized`) + }) + + it('should create proposal with fees matching intent 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 mint = Keypair.generate().publicKey + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, true) + await makeTxSignAndSend(solverProvider, ix) + + // Add validators + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, 1, program) + + const proposalDeadline = now + PROPOSAL_DEADLINE_OFFSET + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = [ + { + mint, + amount: DEFAULT_MAX_FEE_HALF, + }, + ] + + const proposalIx = await solverSdk.createProposalIx(intentHash, instructions, fees, proposalDeadline) + await makeTxSignAndSend(solverProvider, proposalIx) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.fees.length).to.be.eq(1) + expect(proposal.fees[0].mint.toString()).to.be.eq(mint.toString()) + expect(proposal.fees[0].amount.toNumber()).to.be.eq(DEFAULT_MAX_FEE_HALF) + }) + + it('cannot create proposal with fees exceeding 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 mint = Keypair.generate().publicKey + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, true) + await makeTxSignAndSend(solverProvider, ix) + + // Add validators + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, 1, program) + + const proposalDeadline = now + PROPOSAL_DEADLINE_OFFSET + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = [ + { + mint, + amount: DEFAULT_MAX_FEE_EXCEED, // Exceeds max_fee + }, + ] + + const proposalIx = await solverSdk.createProposalIx(intentHash, instructions, fees, proposalDeadline) + const res = await makeTxSignAndSend(solverProvider, proposalIx) + + expect(res).to.be.instanceOf(FailedTransactionMetadata) + expect(res.toString()).to.match(/FeeAmountExceedsMaxFee|Fee amount exceeds max fee/i) + }) + + it('cannot create proposal with fees having wrong mint', 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 mint = Keypair.generate().publicKey + const wrongMint = Keypair.generate().publicKey + + const params = { + op: OpType.Transfer, + user, + nonceHex: nonce, + deadline, + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint, + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [], + } + + const ix = await solverSdk.createIntentIx(intentHash, params, true) + await makeTxSignAndSend(solverProvider, ix) + + // Add validators + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, 1, program) + + const proposalDeadline = now + PROPOSAL_DEADLINE_OFFSET + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: TEST_DATA_HEX_3, + }, + ] + + const fees = [ + { + mint: wrongMint, // Wrong mint + amount: DEFAULT_MAX_FEE_HALF, + }, + ] + + const proposalIx = await solverSdk.createProposalIx(intentHash, instructions, fees, proposalDeadline) + const res = await makeTxSignAndSend(solverProvider, proposalIx) + + expect(res).to.be.instanceOf(FailedTransactionMetadata) + expect(res.toString()).to.match(/InvalidFeeMint|Invalid fee mint/i) + }) + }) + + describe('add_instructions_to_proposal', () => { + const createTestProposal = async (isFinal = false): Promise => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + PROPOSAL_DEADLINE_OFFSET + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + data: DEFAULT_DATA_HEX, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline, isFinal) + await makeTxSignAndSend(solverProvider, ix) + return intentHash + } + + it('should add instructions to proposal', async () => { + const intentHash = await createTestProposal(false) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + data: '040506', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions, false) + await makeTxSignAndSend(solverProvider, ix) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.instructions.length).to.be.eq(2) + expect(Buffer.from(proposal.instructions[0].data).toString('hex')).to.be.eq('010203') + expect(Buffer.from(proposal.instructions[1].data).toString('hex')).to.be.eq('040506') + expect(proposal.isFinal).to.be.false + expect(proposal.instructions[1].accounts.length).to.be.eq(1) + expect(proposal.instructions[1].accounts[0].pubkey.toString()).to.be.eq( + moreInstructions[0].accounts[0].pubkey.toString() + ) + expect(proposal.instructions[1].accounts[0].isSigner).to.be.eq(false) + expect(proposal.instructions[1].accounts[0].isWritable).to.be.eq(true) + }) + + it('should add multiple instructions to proposal', async () => { + const intentHash = await createTestProposal(false) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '070809', + }, + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '0a0b0c', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions, false) + await makeTxSignAndSend(solverProvider, ix) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.instructions.length).to.be.eq(3) + expect(Buffer.from(proposal.instructions[1].data).toString('hex')).to.be.eq('070809') + expect(Buffer.from(proposal.instructions[2].data).toString('hex')).to.be.eq('0a0b0c') + expect(proposal.isFinal).to.be.false + }) + + it('should add instructions to proposal multiple times', async () => { + const intentHash = await createTestProposal(false) + + const moreInstructions1 = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '0d0e0f', + }, + ] + const ix1 = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions1, false) + await makeTxSignAndSend(solverProvider, ix1) + + const moreInstructions2 = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '101112', + }, + ] + const ix2 = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions2, false) + await makeTxSignAndSend(solverProvider, ix2) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.instructions.length).to.be.eq(3) + expect(Buffer.from(proposal.instructions[1].data).toString('hex')).to.be.eq('0d0e0f') + expect(Buffer.from(proposal.instructions[2].data).toString('hex')).to.be.eq('101112') + expect(proposal.isFinal).to.be.false + }) + + it('cannot add instructions if not proposal creator', async () => { + const intentHash = await createTestProposal(false) + const proposalCreator = (await program.account.proposal.fetch(solverSdk.getProposalKey(intentHash))).creator + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '131415', + }, + ] + + const ix = await maliciousSdk.addInstructionsToProposalIx( + intentHash, + moreInstructions, + undefined, + proposalCreator + ) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, `Signer must be proposal creator`) + }) + + it('cannot add instructions to non-existent proposal', async () => { + const intentHash = generateIntentHash() + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '161718', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `AccountNotInitialized`) + }) + + it('cannot add instructions if proposal deadline has passed', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + STALE_CLAIM_DELAY + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '010203', + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline, false) + await makeTxSignAndSend(solverProvider, ix) + + warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '19202a', + }, + ] + + const ix2 = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions) + const res = await makeTxSignAndSend(solverProvider, ix2) + + expectTransactionError(res, 'Proposal has already expired') + }) + + it('cannot add instructions if proposal deadline equals now', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + const now = Number(client.getClock().unixTimestamp) + const deadline = now + SHORT_DEADLINE + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '010203', + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline, false) + await makeTxSignAndSend(solverProvider, ix) + + warpSeconds(provider, WARP_TIME_SHORT) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '1b1c1d', + }, + ] + + const ix2 = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions) + const res = await makeTxSignAndSend(solverProvider, ix2) + + expectTransactionError(res, 'Proposal has already expired') + }) + + it('cannot add instructions if proposal is final', async () => { + const intentHash = await createTestProposal(true) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '1e1f20', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Proposal is already final`) + }) + + it('should finalize proposal when adding instructions with finalize=true', async () => { + const intentHash = await createTestProposal(false) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '212223', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions, true) + await makeTxSignAndSend(solverProvider, ix) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.isFinal).to.be.true + expect(proposal.instructions.length).to.be.eq(2) + }) + + it('should not finalize proposal when adding instructions with finalize=false', async () => { + const intentHash = await createTestProposal(false) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '242526', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions, false) + await makeTxSignAndSend(solverProvider, ix) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.isFinal).to.be.false + expect(proposal.instructions.length).to.be.eq(2) + }) + + it('should finalize proposal by default when adding instructions', async () => { + const intentHash = await createTestProposal(false) + + const moreInstructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [], + data: '272829', + }, + ] + + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions) + await makeTxSignAndSend(solverProvider, ix) + + const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposal.isFinal).to.be.true + expect(proposal.instructions.length).to.be.eq(2) + }) + }) + + describe('claim_stale_proposal', () => { + const createTestProposalWithDeadline = async (deadline: number): Promise => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + + const instructions = [ + { + programId: Keypair.generate().publicKey, + accounts: [ + { + pubkey: Keypair.generate().publicKey, + isSigner: false, + isWritable: true, + }, + ], + data: DEFAULT_DATA_HEX, + }, + ] + + const fees = intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) + + const ix = await solverSdk.createProposalIx(intentHash, instructions, fees, deadline, false) + await makeTxSignAndSend(solverProvider, ix) + return intentHash + } + + it('should claim stale proposal', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + STALE_CLAIM_DELAY + const intentHash = await createTestProposalWithDeadline(deadline) + + const proposalBefore = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect(proposalBefore).to.not.be.null + + warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) + + const proposalBalanceBefore = + Number(provider.client.getBalance(sdk.getProposalKey(intentHash, solver.publicKey))) || 0 + const proposalCreatorBalanceBefore = Number(provider.client.getBalance(proposalBefore.creator)) || 0 + + const ix = await solverSdk.claimStaleProposalIx(intentHash) + await makeTxSignAndSend(solverProvider, ix) + + const proposalBalanceAfter = + Number(provider.client.getBalance(sdk.getProposalKey(intentHash, solver.publicKey))) || 0 + const proposalCreatorBalanceAfter = Number(provider.client.getBalance(proposalBefore.creator)) || 0 + + try { + await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) + expect.fail('Proposal account should be closed') + } catch (error: any) { + expect(error.message).to.include(`Account does not exist`) + } + + expect(proposalCreatorBalanceAfter).to.be.eq( + proposalCreatorBalanceBefore + proposalBalanceBefore - ACCOUNT_CLOSE_FEE + ) + expect(proposalBalanceAfter).to.be.eq(0) + }) + + it('cannot claim proposal if deadline has not passed', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + LONG_DEADLINE + const intentHash = await createTestProposalWithDeadline(deadline) + + warpSeconds(provider, WARP_TIME_SHORT) + + const ix = await solverSdk.claimStaleProposalIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Proposal not yet expired`) + }) + + it('cannot claim proposal if deadline equals now', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + MEDIUM_DEADLINE + const intentHash = await createTestProposalWithDeadline(deadline) + + warpSeconds(provider, MEDIUM_DEADLINE) + + const ix = await solverSdk.claimStaleProposalIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `Proposal not yet expired`) + }) + + it('cannot claim stale proposal if not proposal creator', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + EXPIRATION_TEST_DELAY + const intentHash = await createTestProposalWithDeadline(deadline) + + warpSeconds(provider, EXPIRATION_TEST_DELAY_PLUS_ONE) + + const ix = await maliciousSdk.claimStaleProposalIx(intentHash, solver.publicKey) + const res = await makeTxSignAndSend(maliciousProvider, ix) + + expectTransactionError(res, `Signer must be proposal creator`) + }) + + it('cannot claim non-existent proposal', async () => { + const intentHash = generateIntentHash() + + const ix = await solverSdk.claimStaleProposalIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, `AccountNotInitialized`) + }) + + it('cannot claim proposal twice', async () => { + const now = Number(client.getClock().unixTimestamp) + const deadline = now + DOUBLE_CLAIM_DELAY + const intentHash = await createTestProposalWithDeadline(deadline) + + warpSeconds(provider, DOUBLE_CLAIM_DELAY_PLUS_ONE) + + const ix = await solverSdk.claimStaleProposalIx(intentHash) + await makeTxSignAndSend(solverProvider, ix) + + client.expireBlockhash() + const ix2 = await solverSdk.claimStaleProposalIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix2) + + const errorMsg = res.toString() + expect(errorMsg.includes(`AccountNotInitialized`)).to.be.true + }) + }) }) })