diff --git a/packages/svm/programs/controller/src/types/entity_type.rs b/packages/svm/programs/controller/src/types/entity_type.rs index 1d798cc..5395ce4 100644 --- a/packages/svm/programs/controller/src/types/entity_type.rs +++ b/packages/svm/programs/controller/src/types/entity_type.rs @@ -3,9 +3,9 @@ use anchor_lang::prelude::*; #[repr(u8)] #[derive(Copy, Clone, AnchorSerialize, AnchorDeserialize)] pub enum EntityType { - Validator = 1, - Axia = 2, - Solver = 3, + Validator = 0, + Axia = 1, + Solver = 2, } impl anchor_lang::Space for EntityType { diff --git a/packages/svm/programs/settler/src/instructions/add_axia_sig.rs b/packages/svm/programs/settler/src/instructions/add_axia_sig.rs index df68f9f..2f4a196 100644 --- a/packages/svm/programs/settler/src/instructions/add_axia_sig.rs +++ b/packages/svm/programs/settler/src/instructions/add_axia_sig.rs @@ -16,14 +16,14 @@ pub struct AddAxiaSig<'info> { pub solver: Signer<'info>, #[account( - seeds = [b"entity-registry", &[EntityType::Solver as u8 + 1], solver.key().as_ref()], + seeds = [b"entity-registry", &[EntityType::Solver as u8], solver.key().as_ref()], bump = solver_registry.bump, seeds::program = controller::ID, )] pub solver_registry: Box>, #[account( - seeds = [b"entity-registry", &[EntityType::Axia as u8 + 1], axia_registry.entity_pubkey.as_ref()], + seeds = [b"entity-registry", &[EntityType::Axia as u8], axia_registry.entity_pubkey.as_ref()], bump = axia_registry.bump, seeds::program = controller::ID, )] diff --git a/packages/svm/programs/settler/src/instructions/add_validator_sig.rs b/packages/svm/programs/settler/src/instructions/add_validator_sig.rs index cfe9722..56d46d7 100644 --- a/packages/svm/programs/settler/src/instructions/add_validator_sig.rs +++ b/packages/svm/programs/settler/src/instructions/add_validator_sig.rs @@ -16,7 +16,7 @@ pub struct AddValidatorSig<'info> { pub solver: Signer<'info>, #[account( - seeds = [b"entity-registry", &[EntityType::Solver as u8 + 1], solver.key().as_ref()], + seeds = [b"entity-registry", &[EntityType::Solver as u8], solver.key().as_ref()], bump = solver_registry.bump, seeds::program = controller::ID, )] @@ -38,7 +38,7 @@ pub struct AddValidatorSig<'info> { pub fulfilled_intent: SystemAccount<'info>, #[account( - seeds = [b"entity-registry", &[EntityType::Validator as u8 + 1], validator_registry.entity_pubkey.as_ref()], + seeds = [b"entity-registry", &[EntityType::Validator as u8], validator_registry.entity_pubkey.as_ref()], bump = validator_registry.bump, seeds::program = controller::ID, )] diff --git a/packages/svm/programs/settler/src/instructions/create_intent.rs b/packages/svm/programs/settler/src/instructions/create_intent.rs index f830b88..3c8e2d7 100644 --- a/packages/svm/programs/settler/src/instructions/create_intent.rs +++ b/packages/svm/programs/settler/src/instructions/create_intent.rs @@ -15,7 +15,7 @@ pub struct CreateIntent<'info> { pub solver: Signer<'info>, #[account( - seeds = [b"entity-registry", &[EntityType::Solver as u8 + 1], solver.key().as_ref()], + seeds = [b"entity-registry", &[EntityType::Solver as u8], solver.key().as_ref()], bump = solver_registry.bump, seeds::program = controller::ID )] diff --git a/packages/svm/programs/settler/src/instructions/create_proposal.rs b/packages/svm/programs/settler/src/instructions/create_proposal.rs index 598df6a..1ec3885 100644 --- a/packages/svm/programs/settler/src/instructions/create_proposal.rs +++ b/packages/svm/programs/settler/src/instructions/create_proposal.rs @@ -14,7 +14,7 @@ pub struct CreateProposal<'info> { pub solver: Signer<'info>, #[account( - seeds = [b"entity-registry", &[EntityType::Solver as u8 + 1], solver.key().as_ref()], + seeds = [b"entity-registry", &[EntityType::Solver as u8], solver.key().as_ref()], bump = solver_registry.bump, seeds::program = controller::ID, )] diff --git a/packages/svm/programs/settler/src/instructions/execute_proposal.rs b/packages/svm/programs/settler/src/instructions/execute_proposal.rs index 8f292ff..1e141f5 100644 --- a/packages/svm/programs/settler/src/instructions/execute_proposal.rs +++ b/packages/svm/programs/settler/src/instructions/execute_proposal.rs @@ -13,7 +13,7 @@ pub struct ExecuteProposal<'info> { pub solver: Signer<'info>, #[account( - seeds = [b"entity-registry", &[EntityType::Solver as u8 + 1], solver.key().as_ref()], + seeds = [b"entity-registry", &[EntityType::Solver as u8], solver.key().as_ref()], bump = solver_registry.bump, seeds::program = controller::ID, )] diff --git a/packages/svm/sdks/controller/Controller.ts b/packages/svm/sdks/controller/Controller.ts index b33e514..500fce1 100644 --- a/packages/svm/sdks/controller/Controller.ts +++ b/packages/svm/sdks/controller/Controller.ts @@ -4,9 +4,9 @@ import * as ControllerIDL from '../../target/idl/controller.json' import { Controller } from '../../target/types/controller' export const EntityType = { - Validator: 1, - Axia: 2, - Solver: 3, + Validator: 0, + Axia: 1, + Solver: 2, } as const export type EntityType = (typeof EntityType)[keyof typeof EntityType] diff --git a/packages/svm/sdks/settler/Settler.ts b/packages/svm/sdks/settler/Settler.ts index 6df6fd2..3499ddf 100644 --- a/packages/svm/sdks/settler/Settler.ts +++ b/packages/svm/sdks/settler/Settler.ts @@ -6,6 +6,7 @@ import { Settler } from '../../target/types/settler' import { EntityType } from '../controller/Controller' import { CreateIntentParams, + CreateProposalParams, ExtendIntentParams, IntentEvent, OpType, @@ -111,18 +112,13 @@ export default class SettlerSDK { return ix } - async createProposalIx( - intentHashHex: string, - instructions: ProposalInstruction[], - fees: TokenFee[], - deadline: number, - isFinal = true - ): Promise { + async createProposalIx(intentHashHex: string, params: CreateProposalParams): Promise { + const { instructions, fees, deadline, isFinal } = params const parsedInstructions = this.parseProposalInstructions(instructions) const parsedFees = this.parseTokenFees(fees) const ix = await this.program.methods - .createProposal(parsedInstructions, parsedFees, new BN(deadline), isFinal) + .createProposal(parsedInstructions, parsedFees, new BN(deadline), isFinal ?? false) .accountsPartial({ solver: this.getSignerKey(), solverRegistry: this.getEntityRegistryPubkey(EntityType.Solver, this.getSignerKey()), @@ -225,15 +221,13 @@ export default class SettlerSDK { } 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}`) + const intentHash = Buffer.from(this.parseIntentHashHex(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}`) + const intentHash = Buffer.from(this.parseIntentHashHex(intentHashHex)) return web3.PublicKey.findProgramAddressSync( [Buffer.from('fulfilled-intent'), intentHash], @@ -242,8 +236,7 @@ export default class SettlerSDK { } 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}`) + this.parseIntentHashHex(intentHashHex) const intentKey = this.getIntentKey(intentHashHex) const solver = solverPubkey || this.getSignerKey() @@ -275,13 +268,13 @@ export default class SettlerSDK { } private parseIntentHashHex(intentHashHex: string): number[] { - const intentHash = Buffer.from(intentHashHex, 'hex') + const intentHash = Buffer.from(intentHashHex.slice(2), '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') + const nonce = Buffer.from(nonceHex.slice(2), 'hex') if (nonce.length != 32) throw new Error(`Nonce must be 32 bytes: ${nonceHex}`) return Array.from(nonce) } diff --git a/packages/svm/sdks/settler/types.ts b/packages/svm/sdks/settler/types.ts index 8c9ad85..dee4689 100644 --- a/packages/svm/sdks/settler/types.ts +++ b/packages/svm/sdks/settler/types.ts @@ -35,6 +35,13 @@ export type ExtendIntentParams = { moreEventsHex?: IntentEvent[] } +export type CreateProposalParams = { + instructions: ProposalInstruction[] + fees: TokenFee[] + deadline: number + isFinal?: boolean +} + export type ProposalInstructionAccountMeta = { pubkey: web3.PublicKey isSigner: boolean diff --git a/packages/svm/tests/controller.test.ts b/packages/svm/tests/controller.test.ts index 9288c40..e740023 100644 --- a/packages/svm/tests/controller.test.ts +++ b/packages/svm/tests/controller.test.ts @@ -12,7 +12,7 @@ import path from 'path' import ControllerSDK, { EntityType } from '../sdks/controller/Controller' import * as ControllerIDL from '../target/idl/controller.json' import { Controller } from '../target/types/controller' -import { expectTransactionError, randomKeypair, randomPubkey, toLamports } from './helpers/helpers' +import { expectTransactionError, randomKeypair, randomPubkey, toLamports } from './helpers' import { makeTxSignAndSend, warpSeconds } from './utils' describe('Controller', () => { diff --git a/packages/svm/tests/helpers/constants.ts b/packages/svm/tests/helpers/constants.ts index a7cd412..2a6f26d 100644 --- a/packages/svm/tests/helpers/constants.ts +++ b/packages/svm/tests/helpers/constants.ts @@ -25,7 +25,7 @@ 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_TOPIC_HEX = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' export const DEFAULT_EVENT_DATA_HEX = '040506' export const EMPTY_DATA_HEX = '' export const TEST_DATA_HEX_1 = '070809' diff --git a/packages/svm/tests/helpers/controller.ts b/packages/svm/tests/helpers/controller.ts new file mode 100644 index 0000000..ea083c4 --- /dev/null +++ b/packages/svm/tests/helpers/controller.ts @@ -0,0 +1,20 @@ +import { Keypair } from '@solana/web3.js' +import { LiteSVMProvider } from 'anchor-litesvm' + +import ControllerSDK, { EntityType } from '../../sdks/controller/Controller' +import { makeTxSignAndSend } from '../utils' + +/** + * 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 +} diff --git a/packages/svm/tests/helpers/helpers.ts b/packages/svm/tests/helpers/helpers.ts deleted file mode 100644 index f36b3f0..0000000 --- a/packages/svm/tests/helpers/helpers.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Program, web3 } from '@coral-xyz/anchor' -import { randomHex } from '@mimicprotocol/sdk' -import { signAsync } from '@noble/ed25519' -import { Keypair, PublicKey } from '@solana/web3.js' -import { LiteSVMProvider } from 'anchor-litesvm' -import { expect } from 'chai' -import { FailedTransactionMetadata, LiteSVM, TransactionMetadata } from 'litesvm' - -import ControllerSDK, { EntityType } from '../../sdks/controller/Controller' -import SettlerSDK from '../../sdks/settler/Settler' -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, - 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 -} - -/** - * 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 -} - -/** - * Create an Ed25519 signature for a validator (signs intent hash) - */ -export async function createValidatorSignature(intentHash: string, validator: Keypair): Promise { - const signature = await signAsync(Buffer.from(intentHash, 'hex'), validator.secretKey.slice(0, 32)) - return Array.from(new Uint8Array(signature)) -} - -/** - * Create an Ed25519 signature for an axia (signs proposal key) - */ -export async function createAxiaSignature(proposalKey: PublicKey, axia: Keypair): Promise { - const signature = await signAsync(proposalKey.toBuffer(), axia.secretKey.slice(0, 32)) - return Array.from(new Uint8Array(signature)) -} - -/** - * Helper to expect transaction errors consistently - */ -export function expectTransactionError( - res: TransactionMetadata | FailedTransactionMetadata | string, - expectedMessage: string -): void { - expect(typeof res).to.not.be.eq('TransactionMetadata') - - if (typeof res === 'string') { - expect(res).to.include(expectedMessage) - } else { - expect(res.toString()).to.include(expectedMessage) - } -} - -export function toLamports(sol: number): bigint { - return BigInt(sol * LAMPORTS_PER_SOL) -} - -export function randomKeypair(): web3.Keypair { - return web3.Keypair.generate() -} - -export function randomPubkey(): web3.PublicKey { - return randomKeypair().publicKey -} diff --git a/packages/svm/tests/helpers/index.ts b/packages/svm/tests/helpers/index.ts new file mode 100644 index 0000000..baa32cb --- /dev/null +++ b/packages/svm/tests/helpers/index.ts @@ -0,0 +1,5 @@ +export * from './constants' +export * from './controller' +export * from './intents' +export * from './misc' +export * from './proposals' diff --git a/packages/svm/tests/helpers/intents.ts b/packages/svm/tests/helpers/intents.ts new file mode 100644 index 0000000..8baab26 --- /dev/null +++ b/packages/svm/tests/helpers/intents.ts @@ -0,0 +1,159 @@ +import { Program } from '@coral-xyz/anchor' +import { randomHex } from '@mimicprotocol/sdk' +import { PublicKey } from '@solana/web3.js' +import { LiteSVMProvider } from 'anchor-litesvm' +import { FailedTransactionMetadata, LiteSVM } from 'litesvm' + +import SettlerSDK from '../../sdks/settler/Settler' +import { CreateIntentParams, OpType, 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, + DEFAULT_EVENT_DATA_HEX, + DEFAULT_MAX_FEE, + DEFAULT_MIN_VALIDATIONS, + DEFAULT_TOPIC_HEX, + INTENT_DEADLINE_OFFSET, + INTENT_HASH_LENGTH, +} from './constants' +import { generateNonce, getCurrentTimestamp, randomPubkey } from './misc' + +export type IntentAccount = NonNullable['account']['intent']['fetch']>>> + +export type CreateIntentOptions = Partial & { isFinal?: boolean } + +/** + * Generate a random 32-byte hex string for intent hash + */ +export function generateIntentHash(): string { + return randomHex(INTENT_HASH_LENGTH) +} + +/** + * Create intent params with defaults + * Takes partial params and fills in defaults + */ +export function createIntentParams(client: LiteSVM, params: Partial = {}): CreateIntentParams { + return { + ...getDefaultCreateIntentParams(client), + ...params, + } +} + +/** + * Create a test intent with configurable parameters + */ +export async function createTestIntent( + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + options: CreateIntentOptions = {} +): Promise { + const client = solverProvider.client + const intentHash = generateIntentHash() + const params = createIntentParams(client, options) + + const ix = await solverSdk.createIntentIx(intentHash, params, options.isFinal) + const res = await makeTxSignAndSend(solverProvider, ix) + if (res instanceof FailedTransactionMetadata) { + throw new Error(`Failed to create intent: ${res.toString()}`) + } + return intentHash +} + +/** + * Add mock validators to an intent account + */ +export async function addValidatorsToIntent( + intentHash: string, + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + client: LiteSVM, + numValidators: number +): Promise { + const intentKey = solverSdk.getIntentKey(intentHash) + const program = new Program(SettlerIDL, solverProvider) + + // Fetch and deserialize the intent account + const intent = await program.account.intent.fetch(intentKey) + + // Generate validators + const validators: PublicKey[] = [] + for (let i = 0; i < numValidators; i++) { + validators.push(randomPubkey()) + } + + // Modify the intent to add validators + const modifiedIntent = { + ...intent, + validators, + } + + // Serialize the modified intent back to account data + const serializedData = await program.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: CreateIntentOptions = {} +): Promise { + const intentHash = await createTestIntent(solverSdk, solverProvider, options) + + // Add validators to meet min_validations requirement + const minValidations = options.minValidations ?? DEFAULT_MIN_VALIDATIONS + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, minValidations) + + return intentHash +} + +/** + * Map intent maxFees to TokenFee format + */ +export function mapIntentFeesToTokenFees(intent: IntentAccount): TokenFee[] { + return intent.maxFees.map((maxFee) => ({ + mint: maxFee.mint, + amount: maxFee.amount.toNumber(), + })) +} + +const DEFAULT_CREATE_INTENT_PARAMS: Omit = { + op: OpType.Transfer, + user: randomPubkey(), + nonceHex: generateNonce(), + minValidations: DEFAULT_MIN_VALIDATIONS, + dataHex: DEFAULT_DATA_HEX, + maxFees: [ + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE, + }, + ], + eventsHex: [ + { + topicHex: DEFAULT_TOPIC_HEX, + dataHex: DEFAULT_EVENT_DATA_HEX, + }, + ], +} + +function getDefaultCreateIntentParams(client: LiteSVM): CreateIntentParams { + return { + ...DEFAULT_CREATE_INTENT_PARAMS, + deadline: getCurrentTimestamp(client, INTENT_DEADLINE_OFFSET), + } +} diff --git a/packages/svm/tests/helpers/misc.ts b/packages/svm/tests/helpers/misc.ts new file mode 100644 index 0000000..8cea258 --- /dev/null +++ b/packages/svm/tests/helpers/misc.ts @@ -0,0 +1,46 @@ +import { web3 } from '@coral-xyz/anchor' +import { randomHex } from '@mimicprotocol/sdk' +import { expect } from 'chai' +import { FailedTransactionMetadata, LiteSVM, TransactionMetadata } from 'litesvm' + +import { NONCE_LENGTH } from './constants' + +export const LAMPORTS_PER_SOL = 1_000_000_000 + +/** + * Generate a random 32-byte hex string for nonce + */ +export function generateNonce(): string { + return randomHex(NONCE_LENGTH) +} + +/** + * Get current timestamp with optional offset + */ +export function getCurrentTimestamp(client: LiteSVM, offset = 0): number { + const now = Number(client.getClock().unixTimestamp) + return now + offset +} + +/** + * Helper to expect transaction errors consistently + */ +export function expectTransactionError( + res: TransactionMetadata | FailedTransactionMetadata | string, + expectedMessage: string +): void { + expect(typeof res).to.not.be.eq('TransactionMetadata') + expect(res.toString()).to.include(expectedMessage) +} + +export function toLamports(sol: number): bigint { + return BigInt(sol * LAMPORTS_PER_SOL) +} + +export function randomKeypair(): web3.Keypair { + return web3.Keypair.generate() +} + +export function randomPubkey(): web3.PublicKey { + return randomKeypair().publicKey +} diff --git a/packages/svm/tests/helpers/proposals.ts b/packages/svm/tests/helpers/proposals.ts new file mode 100644 index 0000000..045e685 --- /dev/null +++ b/packages/svm/tests/helpers/proposals.ts @@ -0,0 +1,128 @@ +import { Program } from '@coral-xyz/anchor' +import { PublicKey } from '@solana/web3.js' +import { LiteSVMProvider } from 'anchor-litesvm' +import { FailedTransactionMetadata, LiteSVM } from 'litesvm' + +import SettlerSDK from '../../sdks/settler/Settler' +import { CreateProposalParams, ProposalInstruction } from '../../sdks/settler/types' +import * as SettlerIDL from '../../target/idl/settler.json' +import { Settler } from '../../target/types/settler' +import { makeTxSignAndSend } from '../utils' +import { PROPOSAL_DEADLINE_OFFSET, TEST_DATA_HEX_3 } from './constants' +import { createValidatedIntent, mapIntentFeesToTokenFees } from './intents' +import { getCurrentTimestamp, randomPubkey } from './misc' + +export type InstructionAccount = { + pubkey: PublicKey + isSigner: boolean + isWritable: boolean +} + +export type CreateInstructionAccountOptions = Partial + +export type CreateProposalInstructionOptions = Partial<{ + programId: PublicKey + accounts: Array<{ + pubkey: PublicKey + isSigner: boolean + isWritable: boolean + }> + data: string +}> + +export type CreateProposalIntentOptions = Partial<{ + isFinal: boolean + minValidations: number + deadline: number +}> + +export type CreateProposalOptions = Partial<{ + intentHash: string + intentOptions: CreateProposalIntentOptions + proposalParams: Partial +}> + +/** + * Create proposal params (intent, deadline, instructions, fees) for testing + */ +export async function createProposalParams( + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + client: LiteSVM, + options: CreateProposalOptions = {} +): Promise<{ intentHash: string } & CreateProposalParams> { + const intentHash = + options?.intentHash || + (await createValidatedIntent(solverSdk, solverProvider, client, { ...options.intentOptions })) + + const program = new Program(SettlerIDL, solverProvider) + const intentKey = solverSdk.getIntentKey(intentHash) + const intent = await program.account.intent.fetch(intentKey) + const fees = mapIntentFeesToTokenFees(intent) + + return { + intentHash, + ...(await getDefaultCreateProposalParams(client)), + fees, + ...options.proposalParams, + } +} + +async function getDefaultCreateProposalParams(client: LiteSVM): Promise { + return { + instructions: [createTestProposalInstruction()], + deadline: getCurrentTimestamp(client, PROPOSAL_DEADLINE_OFFSET), + fees: [], + isFinal: true, + } +} + +/** + * Create a finalized proposal + */ +export async function createFinalizedProposal( + solverSdk: SettlerSDK, + solverProvider: LiteSVMProvider, + client: LiteSVM, + options: CreateProposalOptions = {} +): Promise<{ intentHash: string; proposalKey: PublicKey }> { + const { intentHash, ...params } = await createProposalParams(solverSdk, solverProvider, client, options) + + const ix = await solverSdk.createProposalIx(intentHash, { ...params, isFinal: 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 } +} + +/** + * Create a test proposal instruction with sensible defaults + */ +export function createTestProposalInstruction(options: CreateProposalInstructionOptions = {}): ProposalInstruction { + return { + programId: randomPubkey(), + accounts: [], + data: TEST_DATA_HEX_3, + ...options, + } +} + +export function createInstructionAccount(options: CreateInstructionAccountOptions = {}): InstructionAccount { + return { + pubkey: randomPubkey(), + isSigner: false, + isWritable: false, + ...options, + } +} + +export function createWritableInstructionAccount(): InstructionAccount { + return createInstructionAccount({ isWritable: true }) +} + +export function createSignerInstructionAccount(): InstructionAccount { + return createInstructionAccount({ isSigner: true, isWritable: true }) +} diff --git a/packages/svm/tests/settler.test.ts b/packages/svm/tests/settler.test.ts index 8189f31..7416f66 100644 --- a/packages/svm/tests/settler.test.ts +++ b/packages/svm/tests/settler.test.ts @@ -2,39 +2,50 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Program, Wallet } from '@coral-xyz/anchor' -import { signAsync } from '@noble/ed25519' -import { Ed25519Program, Keypair, SYSVAR_INSTRUCTIONS_PUBKEY, TransactionInstruction } from '@solana/web3.js' +import { randomHex } from '@mimicprotocol/sdk' +import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js' import { fromWorkspace, LiteSVMProvider } from 'anchor-litesvm' +import { BN } from 'bn.js' import { expect } from 'chai' import fs from 'fs' -import { FailedTransactionMetadata, LiteSVM } from 'litesvm' +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 { CreateProposalParams, ExtendIntentParams, OpType, ProposalInstruction, TokenFee } 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 { + addValidatorsToIntent, + CreateIntentOptions, + createIntentParams, + CreateProposalOptions, + createProposalParams, + createSignerInstructionAccount, + createTestIntent, + createTestProposalInstruction, + createValidatedIntent, + createWritableInstructionAccount, + expectTransactionError, + generateIntentHash, + generateNonce, + getCurrentTimestamp, + randomKeypair, + randomPubkey, + toLamports, +} from './helpers' import { ACCOUNT_CLOSE_FEE, 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, - 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, PROPOSAL_DEADLINE_OFFSET, SHORT_DEADLINE, STALE_CLAIM_DELAY, @@ -42,25 +53,12 @@ import { TEST_DATA_HEX_1, TEST_DATA_HEX_2, TEST_DATA_HEX_3, - VERY_SHORT_DEADLINE, WARP_TIME_LONG, WARP_TIME_SHORT, } from './helpers/constants' -import { - addValidatorsToIntent, - createAllowlistedEntity, - createAxiaSignature, - createFinalizedProposal, - createTestIntent, - createValidatedIntent, - createValidatorSignature, - expectTransactionError, - generateIntentHash, - generateNonce, -} from './helpers/helpers' import { makeTxSignAndSend, warpSeconds } from './utils' -describe('Settler Program', () => { +describe('Settler', () => { let client: LiteSVM let provider: LiteSVMProvider @@ -83,8 +81,8 @@ describe('Settler Program', () => { admin = Keypair.fromSecretKey( Uint8Array.from(JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config', 'solana', 'id.json'), 'utf8'))) ) - malicious = Keypair.generate() - solver = Keypair.generate() + malicious = randomKeypair() + solver = randomKeypair() client = fromWorkspace(path.join(__dirname, '../')).withBuiltins().withPrecompiles().withSysvars() @@ -98,9 +96,9 @@ describe('Settler Program', () => { 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)) + provider.client.airdrop(admin.publicKey, toLamports(100)) + provider.client.airdrop(malicious.publicKey, toLamports(100)) + provider.client.airdrop(solver.publicKey, toLamports(100)) // Initialize Controller and add Solver to allowlist controllerSdk = new ControllerSDK(provider) @@ -112,15 +110,17 @@ describe('Settler Program', () => { client.expireBlockhash() }) - describe('Settler', () => { - describe('initialize', () => { + describe('initialize', () => { + context('when caller is not deployer', () => { 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.') }) + }) + context('when caller is deployer', () => { it('should call initialize', async () => { const ix = await sdk.initializeIx() await makeTxSignAndSend(provider, ix) @@ -136,1385 +136,1311 @@ describe('Settler Program', () => { 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 - }) + describe('create_intent', () => { + let intentHash: string + let intentOptions: CreateIntentOptions = {} - 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 itThrowsAnError = (errorMessage: string) => { + it('throws an error', async () => { + const params = createIntentParams(client, intentOptions) + const ix = await solverSdk.createIntentIx(intentHash, params, intentOptions.isFinal) const res = await makeTxSignAndSend(solverProvider, ix) + expectTransactionError(res, errorMessage) + }) + } + + context('when caller is an allowlisted solver', () => { + context('when intent data is valid', () => { + context('when intent does not exist', () => { + context('when creating a basic intent', () => { + const intentOptions: CreateIntentOptions = { + op: OpType.Transfer, + user: randomPubkey(), + nonceHex: generateNonce(), + deadline: 10_000, + minValidations: 5, + dataHex: TEST_DATA_HEX_1, + maxFees: [ + { + mint: randomPubkey(), + amount: 1000, + }, + ], + eventsHex: [ + { + topicHex: randomHex(32).slice(2), + dataHex: randomHex(100).slice(2), + }, + ], + isFinal: true, + } + + it('creates the intent with correct properties', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, intentOptions) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + + expect(intent.op).to.deep.include({ transfer: {} }) + expect(intent.user.toString()).to.be.eq(intentOptions.user!.toString()) + expect(intent.creator.toString()).to.be.eq(solver.publicKey.toString()) + expect('0x' + Buffer.from(intent.nonce).toString('hex')).to.be.eq(intentOptions.nonceHex) + expect(intent.deadline.toNumber()).to.be.eq(intentOptions.deadline) + expect(intent.minValidations).to.be.eq(intentOptions.minValidations) + expect(intent.isFinal).to.be.true + expect(Buffer.from(intent.data).toString('hex')).to.be.eq(intentOptions.dataHex) + expect(intent.maxFees.length).to.be.eq(1) + expect(intent.maxFees[0].amount.toNumber()).to.be.eq(1000) + expect(intent.events.length).to.be.eq(1) + expect(intent.validators.length).to.be.eq(0) + expect(Buffer.from(intent.events[0].data).toString('hex')).to.be.eq(intentOptions.eventsHex![0].dataHex) + }) + }) - 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) - }) + context('when creating an intent with empty data', () => { + intentHash = generateIntentHash() + const intentOptions: CreateIntentOptions = { + dataHex: EMPTY_DATA_HEX, + } + + it('creates the intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, intentOptions) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.op).to.deep.include({ transfer: {} }) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq(EMPTY_DATA_HEX) + expect(intent.isFinal).to.be.true + }) + }) - it('should create an intent with is_final true', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - dataHex: EMPTY_DATA_HEX, - eventsHex: [], - isFinal: true, - }) + context('when creating an intent with empty events', () => { + intentHash = generateIntentHash() + const intentOptions: CreateIntentOptions = { + eventsHex: [], + } + + it('creates the intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, intentOptions) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.events.length).to.be.eq(0) + }) + }) - const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) - expect(intent.isFinal).to.be.true - }) + context('when creating an intent with is_final true', () => { + intentHash = generateIntentHash() + const intentOptions: CreateIntentOptions = { + isFinal: true, + } + + it('creates the intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, intentOptions) + 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, + context('when creating an intent with is_final false', () => { + intentHash = generateIntentHash() + const intentOptions: CreateIntentOptions = { + isFinal: false, + } + + it('creates the intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, intentOptions) + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(intent.isFinal).to.be.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 - }) + context('when intent already exists', () => { + context('when fulfilled_intent PDA already exists', () => { + beforeEach('create intent params and mock fulfilled intent', async () => { + intentHash = generateIntentHash() + + // Mock FulfilledIntent + const fulfilledIntent = sdk.getFulfilledIntentKey(intentHash) + client.setAccount(fulfilledIntent, { + executable: false, + lamports: 1002240, + owner: program.programId, + data: Buffer.from('595168911b9267f7' + '010000000000000000', 'hex'), + }) + }) + + itThrowsAnError('AccountNotSystemOwned') + }) - 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) + context('when intent with same hash already exists', () => { + beforeEach('create existing intent', async () => { + intentOptions = {} + intentHash = await createTestIntent(solverSdk, solverProvider) + client.expireBlockhash() + }) - expectTransactionError(res, 'Deadline must be in the future') + itThrowsAnError('already in use') + }) + }) }) - 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') - }) + context('when intent data is invalid', () => { + context('when intent has empty max_fees', () => { + beforeEach('create intent params with empty max_fees', async () => { + intentHash = generateIntentHash() + intentOptions = { maxFees: [] } + }) - 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'), + itThrowsAnError('No max fees provided') }) - const ix = await solverSdk.createIntentIx(intentHash, params, false) - const res = await makeTxSignAndSend(solverProvider, ix) - - expectTransactionError(res, 'AccountNotSystemOwned') - }) + context('when intent has hash shorter than 32 bytes', () => { + let ix: TransactionInstruction + + before('create intent params and ix with invalid hash', async () => { + intentHash = '123456' // invalid - not 32 bytes + intentOptions = {} + + // Build ix with invalid hash + const params = createIntentParams(client, intentOptions) + const { op, user, nonceHex, deadline, minValidations, dataHex, maxFees, eventsHex } = params + + const intentHashParam = Array.from(Buffer.from(intentHash, 'hex')) + const nonce = Array.from(Buffer.from(nonceHex, 'hex')) + const data = Buffer.from(dataHex, 'hex') + const maxFeesBn = maxFees.map((tokenFee) => ({ + ...tokenFee, + amount: new BN(tokenFee.amount), + })) + const events = eventsHex.map((eventHex) => ({ + topic: Array.from(Uint8Array.from(Buffer.from(eventHex.topicHex, 'hex'))), + data: Buffer.from(eventHex.dataHex, 'hex'), + })) + const intentKey = PublicKey.findProgramAddressSync( + [Buffer.from('intent'), Buffer.from(intentHash, 'hex')], + program.programId + )[0] + + ix = await program.methods + .createIntent( + intentHashParam, + data, + maxFeesBn, + events, + minValidations, + solverSdk.opTypeToAnchorEnum(op), + user, + nonce, + new BN(deadline), + false + ) + .accountsPartial({ + intent: intentKey, + solver: solverSdk.getSignerKey(), + solverRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Solver, solver.publicKey), + }) + .instruction() + }) - it('cannot create intent with same intent_hash twice', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - isFinal: false, + it('throws an error calling directly', async () => { + const res = await makeTxSignAndSend(solverProvider, ix) + expectTransactionError(res, 'AnchorError caused by account: intent. Error Code: ConstraintSeeds.') + }) }) - 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) + context('when deadline is invalid', () => { + context('when deadline is in the past', () => { + beforeEach('create intent params with past deadline', async () => { + // Warp as time is likely still t=0 + warpSeconds(solverProvider, WARP_TIME_LONG) + intentHash = generateIntentHash() + intentOptions = { deadline: getCurrentTimestamp(client, -1 * SHORT_DEADLINE) } + }) - expectTransactionError(res, 'already in use') - }) + itThrowsAnError('Deadline must be in the future') + }) - 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`) - } - }) - }) + context('when deadline equals now', () => { + beforeEach('create intent params with deadline equal to now', async () => { + intentHash = generateIntentHash() + intentOptions = { deadline: getCurrentTimestamp(client) } + }) - describe('extend_intent', () => { - it('should extend an intent with more data', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - isFinal: false, + itThrowsAnError('Deadline must be in the future') + }) }) - - 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) + context('when caller is not allowlisted solver', () => { + beforeEach('create intent params', async () => { + intentHash = generateIntentHash() + intentOptions = {} }) - 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('throws an error', async () => { + const params = createIntentParams(client, intentOptions) + const ix = await maliciousSdk.createIntentIx(intentHash, params, false) + const res = await makeTxSignAndSend(maliciousProvider, ix) + expectTransactionError(res, 'AccountNotInitialized') - 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') + const intent = client.getAccount(sdk.getIntentKey(intentHash)) + expect(intent).to.be.null }) + }) + }) - 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) - }) + describe('extend_intent', () => { + let intentHash: string + let intentKey: PublicKey + let extendParams: ExtendIntentParams = {} + + context('when caller is intent creator', () => { + context('when intent exists', () => { + context('when intent is not finalized', () => { + context('when not finalizing intent', () => { + context('when extending once', () => { + context('when extending with more data', () => { + beforeEach('create intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + extendParams = { moreDataHex: randomHex(6).slice(2) } + }) + + it('extends the intent with more data', async () => { + 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( + `${DEFAULT_DATA_HEX}${extendParams.moreDataHex}` + ) + expect(intent.isFinal).to.be.false + }) + }) + + context('when extending with more max_fees', () => { + beforeEach('create intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + extendParams = { + moreMaxFees: [ + { + mint: randomPubkey(), + amount: 2000, + }, + ], + } + }) + + it('extends the intent with more max_fees', async () => { + 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(extendParams.moreMaxFees![0].mint.toString()) + expect(intent.maxFees[1].amount.toNumber()).to.be.eq(extendParams.moreMaxFees![0].amount) + }) + }) + + context('when extending with more events', () => { + beforeEach('create intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + extendParams = { + moreEventsHex: [ + { + topicHex: randomHex(32).slice(2), + dataHex: TEST_DATA_HEX_2, + }, + ], + } + }) + + it('extends the intent with more events', async () => { + 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[1].topic).toString('hex')).to.be.eq( + extendParams.moreEventsHex![0].topicHex + ) + expect(Buffer.from(intent.events[1].data).toString('hex')).to.be.eq( + extendParams.moreEventsHex![0].dataHex + ) + }) + }) + + context('when extending with all optional fields', () => { + beforeEach('create intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { + isFinal: false, + dataHex: TEST_DATA_HEX_1, + }) + extendParams = { + moreDataHex: TEST_DATA_HEX_2, + moreMaxFees: [ + { + mint: randomPubkey(), + amount: 3000, + }, + ], + moreEventsHex: [ + { + topicHex: randomHex(32).slice(2), + dataHex: TEST_DATA_HEX_3, + }, + ], + } + }) + + it('extends the intent with all optional fields', async () => { + 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(`${TEST_DATA_HEX_1}${TEST_DATA_HEX_2}`) + expect(intent.maxFees.length).to.be.eq(2) + expect(intent.maxFees[1].amount.toNumber()).to.be.eq(extendParams.moreMaxFees![0].amount) + expect(intent.events.length).to.be.eq(2) + expect(Buffer.from(intent.events[1].topic).toString('hex')).to.be.eq( + extendParams.moreEventsHex![0].topicHex + ) + expect(Buffer.from(intent.events[1].data).toString('hex')).to.be.eq(TEST_DATA_HEX_3) + }) + }) + }) + + context('when extending more than once', () => { + context('when extending to large size', () => { + const EXTEND_DATA_LOOPS = 100 + const EXTEND_EVENTS_LOOPS = 22 + const EXTEND_MAX_FEES_LOOPS = 18 + + extendParams = { + moreDataHex: randomHex(50).slice(2), + moreEventsHex: [ + { topicHex: randomHex(32).slice(2), dataHex: randomHex(400).slice(2) }, + { topicHex: randomHex(32).slice(2), dataHex: randomHex(400).slice(2) }, + ], + moreMaxFees: [ + { mint: randomPubkey(), amount: 1 }, + { mint: randomPubkey(), amount: 1 + 1000 }, + { mint: randomPubkey(), amount: 1 + 2000 }, + ], + } + + before('create intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { + isFinal: false, + dataHex: '', + eventsHex: [], + }) + intentKey = sdk.getIntentKey(intentHash) + }) + + const itExtendsIntentWithoutFailing = async ( + fieldName: string, + loops: number, + extendParams: ExtendIntentParams + ) => { + it(`extends intent ${fieldName} without failing`, async () => { + for (let i = 0; i < loops; i++) { + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + const res = await makeTxSignAndSend(solverProvider, ix) + expect(res.toString()).to.include(`Program ${program.programId} success`) + client.expireBlockhash() + } + }) + } + + itExtendsIntentWithoutFailing('data', EXTEND_DATA_LOOPS, { moreDataHex: extendParams.moreDataHex }) + + itExtendsIntentWithoutFailing('events', EXTEND_EVENTS_LOOPS, { + moreEventsHex: extendParams.moreEventsHex, + }) + + itExtendsIntentWithoutFailing('max fees', EXTEND_MAX_FEES_LOOPS, { + moreMaxFees: extendParams.moreMaxFees, + }) + + it('extended the intent fields as expected', async () => { + const intent = await program.account.intent.fetch(intentKey) + const intentAcc = client.getAccount(intentKey) + expect(intent.data.length).to.be.eq(5000) + expect(intent.maxFees.length).to.be.eq(55) + expect(intent.events.length).to.be.eq(44) + expect(intent.isFinal).to.be.false + expect(intentAcc?.data.length).to.be.eq(26581) + }) + }) + + context('when extending multiple times', () => { + let extendParams1: ExtendIntentParams + let extendParams2: ExtendIntentParams + + before('create intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { + isFinal: false, + dataHex: TEST_DATA_HEX_1, + }) + extendParams1 = { moreDataHex: randomHex(6).slice(2) } + extendParams2 = { moreDataHex: randomHex(6).slice(2) } + }) + + it('extends the intent once without failing', async () => { + const ix = await solverSdk.extendIntentIx(intentHash, extendParams1, false) + await makeTxSignAndSend(solverProvider, ix) + }) + + it('extends the intent again without failing', async () => { + const ix = await solverSdk.extendIntentIx(intentHash, extendParams2, false) + await makeTxSignAndSend(solverProvider, ix) + }) + + it('extended the intent as expected', async () => { + const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + expect(Buffer.from(intent.data).toString('hex')).to.be.eq( + `${TEST_DATA_HEX_1}${extendParams1.moreDataHex}${extendParams2.moreDataHex}` + ) + expect(intent.isFinal).to.be.false + }) + }) + }) + }) - it('should finalize an intent', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + context('when finalizing intent', () => { + context('when finalizing an intent', () => { + beforeEach('create intent', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + extendParams = {} + }) + + it('finalizes the intent', async () => { + 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 + }) + }) + + context('when extending and finalizing in one call', () => { + beforeEach('create intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { + isFinal: false, + dataHex: TEST_DATA_HEX_2, + }) + extendParams = { moreDataHex: randomHex(6).slice(2) } + }) + + it('extends and finalizes the intent in one call', async () => { + 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( + `${TEST_DATA_HEX_2}${extendParams.moreDataHex}` + ) + expect(intent.isFinal).to.be.true + }) + }) + }) + }) - const extendParams = {} + context('when intent is already finalized', () => { + beforeEach('create finalized intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: true }) + extendParams = { moreDataHex: TEST_DATA_HEX_1 } + }) - const ix = await solverSdk.extendIntentIx(intentHash, extendParams, true) - await makeTxSignAndSend(solverProvider, ix) + it('throws an error', async () => { + const ix = await solverSdk.extendIntentIx(intentHash, extendParams, false) + const res = await makeTxSignAndSend(solverProvider, ix) - const intent = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) - expect(intent.isFinal).to.be.true + expectTransactionError(res, `Intent is already final`) + }) + }) }) - it('should extend and finalize an intent in one call', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) - - const extendParams = { - moreDataHex: '191a1b', - } + context('when intent does not exist', () => { + beforeEach('generate non-existent intent hash and extend params', () => { + intentHash = generateIntentHash() + extendParams = { moreDataHex: randomHex(6).slice(2) } + }) - const ix = await solverSdk.extendIntentIx(intentHash, extendParams, true) - await makeTxSignAndSend(solverProvider, ix) + it('throws an error', async () => { + const ix = await sdk.extendIntentIx(intentHash, extendParams, false) + const res = await makeTxSignAndSend(provider, 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 + expectTransactionError(res, `AccountNotInitialized`) + }) }) + }) - 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 + context('when caller is not intent creator', () => { + beforeEach('create intent and extend params', async () => { + intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) + extendParams = { moreDataHex: randomHex(6).slice(2) } }) - it('cannot extend intent if not intent creator', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { isFinal: false }) - - const extendParams = { - moreDataHex: '222324', - } - + it('throws an error', async () => { 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) + describe('claim_stale_intent', () => { + let intentHash: string + + context('when caller is intent creator', () => { + context('when intent exists', () => { + context('when intent is stale', () => { + context('when intent is final', () => { + before('create final stale intent', async () => { + const deadline = getCurrentTimestamp(client, STALE_CLAIM_DELAY) + intentHash = await createTestIntent(solverSdk, solverProvider, { deadline, isFinal: true }) + + warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) + }) + + it('claims the stale intent', async () => { + const intentBefore = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + 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 the stale intent again', async () => { + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + + expectTransactionError(res, 'AccountNotInitialized') + }) + }) - const intentBefore = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) - expect(intentBefore).to.not.be.null + context('when intent is not final', () => { + before('create not final stale intent', async () => { + const deadline = getCurrentTimestamp(client, STALE_CLAIM_DELAY) + intentHash = await createTestIntent(solverSdk, solverProvider, { deadline, isFinal: false }) - warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) + 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 + it('claims the stale intent', async () => { + const intentBefore = await program.account.intent.fetch(sdk.getIntentKey(intentHash)) + 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 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 + 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`) - } + 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) - }) + 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) + it('cannot claim the stale intent again', async () => { + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) - warpSeconds(provider, WARP_TIME_SHORT) + expectTransactionError(res, 'AccountNotInitialized') + }) + }) + }) - const ix = await solverSdk.claimStaleIntentIx(intentHash) - const res = await makeTxSignAndSend(solverProvider, ix) + context('when intent is not stale', () => { + context('when deadline is in the future', () => { + beforeEach('create intent and warp time', async () => { + const deadline = getCurrentTimestamp(client, LONG_DEADLINE) + intentHash = await createTestIntent(solverSdk, solverProvider, { deadline }) + warpSeconds(provider, WARP_TIME_SHORT) + }) + + it('throws an error', async () => { + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + expectTransactionError(res, 'Intent not yet expired') + }) + }) - expectTransactionError(res, 'Intent not yet expired') + context('when deadline equals now', () => { + beforeEach('create intent and warp time', async () => { + const deadline = getCurrentTimestamp(client, MEDIUM_DEADLINE) + intentHash = await createTestIntent(solverSdk, solverProvider, { deadline }) + warpSeconds(provider, MEDIUM_DEADLINE) + }) + + it('throws an error', async () => { + 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) + context('when intent does not exist', () => { + beforeEach('generate non-existent intent hash', () => { + intentHash = generateIntentHash() + }) - expectTransactionError(res, 'Intent not yet expired') + it('throws an error', async () => { + const ix = await solverSdk.claimStaleIntentIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) + expectTransactionError(res, `AccountNotInitialized`) + }) }) + }) - 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) - + context('when caller is not intent creator', () => { + beforeEach('create intent and warp time', async () => { + const deadline = getCurrentTimestamp(client, EXPIRATION_TEST_DELAY) + intentHash = await createTestIntent(solverSdk, solverProvider, { deadline }) warpSeconds(provider, EXPIRATION_TEST_DELAY_PLUS_ONE) + }) + it('throws an error', async () => { 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 - }) }) + }) - 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) - }) + describe('create_proposal', () => { + let params: CreateProposalParams & { intentHash: string } + + const createProposalFromParams = async () => { + const ix = await solverSdk.createProposalIx(params.intentHash, params) + return await makeTxSignAndSend(solverProvider, ix) + } + + const itThrowsAnErrorWhenCreatingProposalFromParams = async (error: string) => { + it('throws an error', async () => { + const res = await createProposalFromParams() + expectTransactionError(res, error) + }) + } + + context('when caller is whitelisted solver', () => { + context('when intent exists', () => { + context('when intent conditions are met', () => { + context('when proposal data is valid', () => { + context('when creating a basic proposal', () => { + beforeEach('create intent and proposal params', async () => { + params = await createProposalParams(solverSdk, solverProvider, client, { + proposalParams: { + instructions: [ + createTestProposalInstruction({ + accounts: [createWritableInstructionAccount()], + data: TEST_DATA_HEX_1, + }), + ], + }, + }) + }) + + it('creates the proposal', async () => { + await createProposalFromParams() + + const proposal = await program.account.proposal.fetch( + sdk.getProposalKey(params.intentHash, solver.publicKey) + ) + expect(proposal.intent.toString()).to.be.eq(sdk.getIntentKey(params.intentHash).toString()) + expect(proposal.creator.toString()).to.be.eq(solver.publicKey.toString()) + expect(proposal.deadline.toNumber()).to.be.eq(params.deadline) + expect(proposal.isFinal).to.be.true + expect(proposal.instructions.length).to.be.eq(1) + expect(proposal.instructions[0].programId.toString()).to.be.eq( + params.instructions[0].programId.toString() + ) + expect(Buffer.from(proposal.instructions[0].data).toString('hex')).to.be.eq(TEST_DATA_HEX_1) + expect(proposal.instructions[0].accounts.length).to.be.eq(1) + expect(proposal.instructions[0].accounts[0].pubkey.toString()).to.be.eq( + params.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) + }) + }) + + context('when creating a proposal with multiple instructions', () => { + beforeEach('create intent and proposal params', async () => { + params = await createProposalParams(solverSdk, solverProvider, client, { + proposalParams: { + instructions: [ + createTestProposalInstruction({ + accounts: [createWritableInstructionAccount()], + data: TEST_DATA_HEX_1, + }), + createTestProposalInstruction({ + accounts: [createSignerInstructionAccount()], + data: TEST_DATA_HEX_2, + }), + ], + }, + }) + }) + + it('creates the proposal with multiple instructions', async () => { + await createProposalFromParams() + + const proposal = await program.account.proposal.fetch( + sdk.getProposalKey(params.intentHash, solver.publicKey) + ) + expect(proposal.instructions.length).to.be.eq(2) + expect(Buffer.from(proposal.instructions[0].data).toString('hex')).to.be.eq(TEST_DATA_HEX_1) + expect(Buffer.from(proposal.instructions[1].data).toString('hex')).to.be.eq(TEST_DATA_HEX_2) + 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( + params.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( + params.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(true) + }) + }) + + context('when creating a proposal with empty instructions', () => { + beforeEach('create intent and proposal params', async () => { + params = await createProposalParams(solverSdk, solverProvider, client, { + proposalParams: { + instructions: [], + }, + }) + }) + + it('creates the proposal with empty instructions', async () => { + await createProposalFromParams() + + const proposal = await program.account.proposal.fetch( + sdk.getProposalKey(params.intentHash, solver.publicKey) + ) + expect(proposal.instructions.length).to.be.eq(0) + }) + }) + + context('when creating proposal with fees matching intent max_fees', () => { + const testMaxFees = [ + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE, + }, + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE * 2, + }, + ] + + beforeEach('create intent and proposal params', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { + maxFees: testMaxFees, + }) + + params = await createProposalParams(solverSdk, solverProvider, client, { + intentHash, + proposalParams: { fees: testMaxFees }, + }) + }) + + it('creates proposal with correct fees', async () => { + await createProposalFromParams() + + const proposal = await program.account.proposal.fetch( + sdk.getProposalKey(params.intentHash, solver.publicKey) + ) + expect(proposal.fees.length).to.be.eq(2) + expect(proposal.fees[0].mint.toString()).to.be.eq(testMaxFees[0].mint.toString()) + expect(proposal.fees[0].amount.toString()).to.be.eq(testMaxFees[0].amount.toString()) + expect(proposal.fees[1].mint.toString()).to.be.eq(testMaxFees[1].mint.toString()) + expect(proposal.fees[1].amount.toString()).to.be.eq(testMaxFees[1].amount.toString()) + }) + }) + }) - 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 + context('when proposal data is invalid', () => { + context('when deadline is invalid', () => { + context('when deadline is in the past', () => { + beforeEach('create intent and proposal params with past deadline', async () => { + const deadline = getCurrentTimestamp(client, -1 * SHORT_DEADLINE) + params = await createProposalParams(solverSdk, solverProvider, client, { + proposalParams: { deadline }, + }) + }) + + itThrowsAnErrorWhenCreatingProposalFromParams('Deadline must be in the future') + }) + + context('when deadline equals now', () => { + beforeEach('create intent and proposal params with deadline equal to now', async () => { + const deadline = getCurrentTimestamp(client) + params = await createProposalParams(solverSdk, solverProvider, client, { + proposalParams: { deadline }, + }) + }) + + itThrowsAnErrorWhenCreatingProposalFromParams('Deadline must be in the future') + }) + + context('when proposal deadline exceeds intent deadline', () => { + beforeEach('create intent and proposal params with deadline exceeding intent', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client) + const intentDeadline = Number( + (await program.account.intent.fetch(sdk.getIntentKey(intentHash))).deadline + ) + + params = await createProposalParams(solverSdk, solverProvider, client, { + intentHash, + proposalParams: { deadline: intentDeadline + SHORT_DEADLINE }, + }) + }) + + itThrowsAnErrorWhenCreatingProposalFromParams(`Proposal deadline can't be after the Intent's deadline`) + }) + }) + + context('when fees are invalid', () => { + context('when fees exceed max_fees', () => { + const testMaxFees = [ + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE, + }, + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE * 2, + }, + ] + + const largerMaxFees = [testMaxFees[0], { ...testMaxFees[1], amount: testMaxFees[1].amount + 10 }] + + beforeEach('create intent and proposal params', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { + maxFees: testMaxFees, + }) + + params = await createProposalParams(solverSdk, solverProvider, client, { + intentHash, + proposalParams: { fees: largerMaxFees }, + }) + }) + + itThrowsAnErrorWhenCreatingProposalFromParams('FeeAmountExceedsMaxFee') + }) + + context('when fees have wrong mint', () => { + const testMaxFees = [ + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE, + }, + ] + + const otherMaxFees = [ + { + mint: randomPubkey(), + amount: DEFAULT_MAX_FEE, + }, + ] + + beforeEach('create intent and proposal params', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { + maxFees: testMaxFees, + }) + + params = await createProposalParams(solverSdk, solverProvider, client, { + intentHash, + proposalParams: { fees: otherMaxFees }, + }) + }) + + itThrowsAnErrorWhenCreatingProposalFromParams('InvalidFeeMint') + }) + }) + + context('when proposal with same intent_hash and solver already exists', () => { + let proposalKey: PublicKey + let expectedError = '' + + beforeEach('create intent, proposal params, and create first proposal', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + + params = await createProposalParams(solverSdk, solverProvider, client, { intentHash }) + + const ix = await solverSdk.createProposalIx(intentHash, params) + await makeTxSignAndSend(solverProvider, ix) + client.expireBlockhash() + + proposalKey = solverSdk.getProposalKey(intentHash) + expectedError = `Allocate: account Address { address: ${proposalKey}, base: None } already in use` + }) + + itThrowsAnErrorWhenCreatingProposalFromParams(expectedError) + }) + }) + }) - const instructions: any[] = [] + context('when intent conditions are not met', () => { + context('when intent deadline has passed', () => { + beforeEach('create intent with short deadline and expire it', async () => { + const intentDeadline = getCurrentTimestamp(client, SHORT_DEADLINE) + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { + deadline: intentDeadline, + }) - const fees = intent.maxFees.map((maxFee) => ({ - mint: maxFee.mint, - amount: maxFee.amount.toNumber(), - })) + warpSeconds(provider, intentDeadline + 10) - 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()}`) - } + params = await createProposalParams(solverSdk, solverProvider, client, { intentHash }) + }) - const proposal = await program.account.proposal.fetch(sdk.getProposalKey(intentHash, solver.publicKey)) - expect(proposal.instructions.length).to.be.eq(0) - }) + itThrowsAnErrorWhenCreatingProposalFromParams('Intent has already expired') + }) - 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') - }) + context('when intent has insufficient validations', () => { + beforeEach('create intent with insufficient validations', async () => { + const intentHash = await createTestIntent(solverSdk, solverProvider, { minValidations: 2 }) + await addValidatorsToIntent(intentHash, solverSdk, solverProvider, client, 1) - 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) + params = await createProposalParams(solverSdk, solverProvider, client, { intentHash }) + }) - expectTransactionError(res, `Deadline must be in the future`) - }) + itThrowsAnErrorWhenCreatingProposalFromParams('Intent has insufficient validations') + }) - 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) + context('when intent is not final', () => { + beforeEach('create non-final intent and proposal params', async () => { + params = await createProposalParams(solverSdk, solverProvider, client, { + intentOptions: { isFinal: false }, + }) + }) - expectTransactionError(res, `Deadline must be in the future`) - }) + itThrowsAnErrorWhenCreatingProposalFromParams('Intent is not final') + }) - 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`) + context('when fulfilled_intent PDA already exists', () => { + beforeEach('create intent, mock fulfilled intent, and proposal params', async () => { + const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { isFinal: true }) + + // Mock FulfilledIntent + const fulfilledIntent = sdk.getFulfilledIntentKey(intentHash) + client.setAccount(fulfilledIntent, { + executable: false, + lamports: 1002240, + owner: program.programId, + data: Buffer.from('595168911b9267f7' + '010000000000000000', 'hex'), + }) + + params = await createProposalParams(solverSdk, solverProvider, client, { intentHash }) + }) + + itThrowsAnErrorWhenCreatingProposalFromParams( + 'AnchorError caused by account: fulfilled_intent. Error Code: AccountNotSystemOwned' + ) + }) + }) }) - 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 + context('when intent does not exist', () => { + let intentHash: string + let deadline: number + let instructions: ProposalInstruction[] + let fees: TokenFee[] - const instructions = [ - { - programId: Keypair.generate().publicKey, - accounts: [], - data: TEST_DATA_HEX_3, - }, - ] + beforeEach('generate non-existent intent hash and proposal params', async () => { + intentHash = generateIntentHash() + deadline = getCurrentTimestamp(client, PROPOSAL_DEADLINE_OFFSET) + instructions = [createTestProposalInstruction()] + fees = [] + }) - const ix = await solverSdk.createProposalIx(intentHash, instructions, [], proposalDeadline) - const res = await makeTxSignAndSend(solverProvider, ix) + it('throws an error', async () => { + const ix = await solverSdk.createProposalIx(intentHash, { instructions, deadline, fees, isFinal: true }) + const res = await makeTxSignAndSend(solverProvider, ix) - expectTransactionError(res, `Proposal deadline can't be after the Intent's deadline`) + expectTransactionError(res, 'AnchorError caused by account: intent. Error Code: AccountNotInitialized') + }) }) + }) - 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`) + context('when caller is not whitelisted solver', () => { + beforeEach('create intent and proposal params', async () => { + params = await createProposalParams(solverSdk, solverProvider, client) }) - 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('throws an error', async () => { + const ix = await maliciousSdk.createProposalIx(params.intentHash, params) + const res = await makeTxSignAndSend(maliciousProvider, ix) + expectTransactionError(res, 'AccountNotInitialized') }) + }) + }) - 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'), - }) + describe('add_instructions_to_proposal', () => { + const createTestProposal = async (options?: CreateProposalOptions): Promise => { + const params = await createProposalParams(solverSdk, solverProvider, client, options) + const ix = await solverSdk.createProposalIx(params.intentHash, params) + await makeTxSignAndSend(solverProvider, ix) + return params.intentHash + } - 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 itThrowsAnError = (error: string) => { + it('throws an error', async () => { + const ix = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions) const res = await makeTxSignAndSend(solverProvider, ix) + expectTransactionError(res, error) + }) + } + + let intentHash: string + let moreInstructions: ProposalInstruction[] + + context('when caller is proposal creator', () => { + context('when proposal exists', () => { + context('when proposal data is valid', () => { + context('when not finalizing the proposal', () => { + context('when calling once', () => { + context('when adding a single instruction', () => { + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + + moreInstructions = [ + createTestProposalInstruction({ + programId: randomPubkey(), + accounts: [createWritableInstructionAccount()], + data: TEST_DATA_HEX_1, + }), + ] + }) + + it('adds the instruction to the proposal', async () => { + 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(TEST_DATA_HEX_3) + expect(Buffer.from(proposal.instructions[1].data).toString('hex')).to.be.eq(TEST_DATA_HEX_1) + expect(proposal.isFinal).to.be.false + expect(proposal.instructions[1].programId.toString()).to.be.eq( + moreInstructions[0].programId.toString() + ) + 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) + }) + }) + + context('when adding multiple instructions', () => { + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + + moreInstructions = [ + createTestProposalInstruction({ data: TEST_DATA_HEX_1 }), + createTestProposalInstruction({ data: TEST_DATA_HEX_2 }), + ] + }) + + it('adds multiple instructions to the proposal', async () => { + 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(TEST_DATA_HEX_1) + expect(Buffer.from(proposal.instructions[2].data).toString('hex')).to.be.eq(TEST_DATA_HEX_2) + expect(proposal.isFinal).to.be.false + }) + }) + + context('when passing finalize=false', () => { + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + + moreInstructions = [createTestProposalInstruction()] + }) + + it('does not finalize the proposal', async () => { + 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) + }) + }) + }) + + context('when calling more than once', () => { + context('when adding instructions multiple times', () => { + let moreInstructions1: ProposalInstruction[] + let moreInstructions2: ProposalInstruction[] + + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + + moreInstructions1 = [createTestProposalInstruction({ data: TEST_DATA_HEX_1 })] + moreInstructions2 = [createTestProposalInstruction({ data: TEST_DATA_HEX_2 })] + }) + + it('adds instructions to the proposal multiple times', async () => { + const ix1 = await solverSdk.addInstructionsToProposalIx(intentHash, moreInstructions1, false) + await makeTxSignAndSend(solverProvider, ix1) + + 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(TEST_DATA_HEX_1) + expect(Buffer.from(proposal.instructions[2].data).toString('hex')).to.be.eq(TEST_DATA_HEX_2) + expect(proposal.isFinal).to.be.false + }) + }) + }) + }) - 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, - }, - ] + context('when finalizing the proposal', () => { + context('when passing finalize=true', () => { + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + moreInstructions = [createTestProposalInstruction()] + }) + + it('finalizes the proposal', async () => { + 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) + }) + }) + + context('when not passing any value to the "finalize" parameter', () => { + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + moreInstructions = [createTestProposalInstruction()] + }) + + it('finalizes the proposal by default', async () => { + 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) + }) + }) + }) + }) - const ix = await solverSdk.createProposalIx(intentHash, instructions, [], deadline) - const res = await makeTxSignAndSend(solverProvider, ix) + context('when proposal data is not valid', () => { + context('when proposal is not final', () => { + context('when proposal has expired', () => { + context('when proposal deadline has passed', () => { + beforeEach('create proposal with short deadline and warp time', async () => { + intentHash = await createTestProposal({ + proposalParams: { deadline: getCurrentTimestamp(client, SHORT_DEADLINE) }, + }) + warpSeconds(solverProvider, WARP_TIME_LONG) + moreInstructions = [] + }) + + itThrowsAnError('Proposal has already expired') + }) + + context('when proposal deadline equals now', () => { + beforeEach('create proposal with short deadline and warp time', async () => { + intentHash = await createTestProposal({ + proposalParams: { deadline: getCurrentTimestamp(client, SHORT_DEADLINE) }, + }) + warpSeconds(solverProvider, SHORT_DEADLINE) + moreInstructions = [] + }) + + itThrowsAnError('Proposal has already expired') + }) + }) + }) - expectTransactionError(res, `AccountNotInitialized`) - }) + context('when proposal is final', () => { + beforeEach('create finalized proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: true } }) + moreInstructions = [] + }) - 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) + itThrowsAnError('Proposal is already final') + }) + }) }) - 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) - }) + context('when proposal does not exist', () => { + beforeEach('generate non-existent intent hash and instruction params', () => { + intentHash = generateIntentHash() + moreInstructions = [] + }) - 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) + itThrowsAnError('AccountNotInitialized') }) }) - 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 - }) + context('when caller is not proposal creator', () => { + let proposalCreator: PublicKey - 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 + beforeEach('create proposal and instruction params', async () => { + intentHash = await createTestProposal({ proposalParams: { isFinal: false } }) + proposalCreator = (await program.account.proposal.fetch(solverSdk.getProposalKey(intentHash))).creator + moreInstructions = [] }) - 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', - }, - ] - + it('throws an error', async () => { const ix = await maliciousSdk.addInstructionsToProposalIx( intentHash, moreInstructions, @@ -1525,1023 +1451,120 @@ describe('Settler Program', () => { 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() + describe('claim_stale_proposal', () => { + const createTestProposal = async (options?: CreateProposalOptions): Promise => { + const params = await createProposalParams(solverSdk, solverProvider, client, options) + const ix = await solverSdk.createProposalIx(params.intentHash, params) + await makeTxSignAndSend(solverProvider, ix) + return params.intentHash + } + const itThrowsAnError = (error: string) => { + it('throws an error', async () => { 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 + expectTransactionError(res, error) }) - }) - - describe('add_validator_sigs', () => { - let whitelistedValidator: Keypair - - before(async () => { - whitelistedValidator = Keypair.generate() - const whitelistValidatorIx = await controllerSdk.setAllowedEntityIx( - EntityType.Validator, - whitelistedValidator.publicKey - ) - await makeTxSignAndSend(provider, whitelistValidatorIx) - }) - - it('should add validator signature successfully', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const signature = await createValidatorSignature(intentHash, whitelistedValidator) + } - const intentBefore = await program.account.intent.fetch(intentKey) - expect(intentBefore.validators.length).to.be.eq(0) - - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - await makeTxSignAndSend(solverProvider, ...ixs) - - const intentAfter = await program.account.intent.fetch(intentKey) - expect(intentAfter.validators.length).to.be.eq(1) - expect(intentAfter.validators[0].toString()).to.be.eq(whitelistedValidator.publicKey.toString()) - }) - - it('should add multiple validator signatures', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - minValidations: MULTIPLE_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const validator1 = await createAllowlistedEntity(controllerSdk, provider, EntityType.Validator) - const validator2 = await createAllowlistedEntity(controllerSdk, provider, EntityType.Validator) - const validator3 = await createAllowlistedEntity(controllerSdk, provider, EntityType.Validator) - - const signature1 = await createValidatorSignature(intentHash, validator1) - const ixs1 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - validator1.publicKey, - signature1 - ) - await makeTxSignAndSend(solverProvider, ...ixs1) - - const intentAfter1 = await program.account.intent.fetch(intentKey) - expect(intentAfter1.validators.length).to.be.eq(1) - - const signature2 = await createValidatorSignature(intentHash, validator2) - const ixs2 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - validator2.publicKey, - signature2 - ) - await makeTxSignAndSend(solverProvider, ...ixs2) - - const intentAfter2 = await program.account.intent.fetch(intentKey) - expect(intentAfter2.validators.length).to.be.eq(2) - - const signature3 = await createValidatorSignature(intentHash, validator3) - const ixs3 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - validator3.publicKey, - signature3 - ) - await makeTxSignAndSend(solverProvider, ...ixs3) - - const intentAfter3 = await program.account.intent.fetch(intentKey) - expect(intentAfter3.validators.length).to.be.eq(3) - expect(intentAfter3.validators.map((v) => v.toString())).to.include(validator1.publicKey.toString()) - expect(intentAfter3.validators.map((v) => v.toString())).to.include(validator2.publicKey.toString()) - expect(intentAfter3.validators.map((v) => v.toString())).to.include(validator3.publicKey.toString()) - }) + let intentHash: string - it('should handle duplicate validator signature gracefully', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { minValidations: 2, isFinal: true }) - const intentKey = sdk.getIntentKey(intentHash) + context('when caller is proposal creator', () => { + context('when proposal exists', () => { + context('when proposal is stale', () => { + let proposalKey: PublicKey - const signature = await createValidatorSignature(intentHash, whitelistedValidator) - - const ixs1 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - await makeTxSignAndSend(solverProvider, ...ixs1) - - const intentAfter1 = await program.account.intent.fetch(intentKey) - expect(intentAfter1.validators.length).to.be.eq(1) - - client.expireBlockhash() - const ixs2 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - await makeTxSignAndSend(solverProvider, ...ixs2) - - const intentAfter2 = await program.account.intent.fetch(intentKey) - expect(intentAfter2.validators.length).to.be.eq(1) - }) - - it('should handle when min_validations already met', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const validator1 = await createAllowlistedEntity(controllerSdk, provider, EntityType.Validator) - const validator2 = await createAllowlistedEntity(controllerSdk, provider, EntityType.Validator) - - const signature1 = await createValidatorSignature(intentHash, validator1) - const ixs1 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - validator1.publicKey, - signature1 - ) - await makeTxSignAndSend(solverProvider, ...ixs1) - - const intentAfter1 = await program.account.intent.fetch(intentKey) - expect(intentAfter1.validators.length).to.be.eq(1) - expect(intentAfter1.minValidations).to.be.eq(1) - - client.expireBlockhash() - const signature2 = await createValidatorSignature(intentHash, validator2) - const ixs2 = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - validator2.publicKey, - signature2 - ) - await makeTxSignAndSend(solverProvider, ...ixs2) - - const intentAfter2 = await program.account.intent.fetch(intentKey) - expect(intentAfter2.validators.length).to.be.eq(1) - expect(intentAfter2.validators[0].toString()).to.be.eq(validator1.publicKey.toString()) - }) - - it('cannot add signature if intent is not final', async () => { - const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: false, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const signature = await createValidatorSignature(intentHash, whitelistedValidator) - - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `Intent is not final`) - }) - - it('cannot add signature if intent has expired', async () => { - 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: DEFAULT_DATA_HEX, - maxFees: [ - { - mint: Keypair.generate().publicKey, - amount: DEFAULT_MAX_FEE, - }, - ], - eventsHex: [], - } - - const ix = await solverSdk.createIntentIx(intentHash, params, true) - await makeTxSignAndSend(solverProvider, ix) - - const intentKey = sdk.getIntentKey(intentHash) - - warpSeconds(provider, 101) - - const signature = await createValidatorSignature(intentHash, whitelistedValidator) - - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `Intent has already expired`) - }) - - it('cannot add signature if validator is not whitelisted', async () => { - const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const validator = Keypair.generate() - - const signature = await createValidatorSignature(intentHash, validator) - - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - validator.publicKey, - signature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError( - res, - `AnchorError caused by account: validator_registry. Error Code: AccountNotInitialized.` - ) - }) - - it('cannot add signature if solver is not whitelisted', async () => { - const intentHash = await createTestIntent(solverSdk, solverProvider, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const signature = await createValidatorSignature(intentHash, whitelistedValidator) - - const ixs = await maliciousSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - const res = await makeTxSignAndSend(maliciousProvider, ...ixs) + before('create proposal with short deadline and warp time', async () => { + intentHash = await createTestProposal({ + proposalParams: { deadline: getCurrentTimestamp(client, STALE_CLAIM_DELAY) }, + }) + proposalKey = solverSdk.getProposalKey(intentHash) + warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) + }) - expectTransactionError( - res, - `AnchorError caused by account: solver_registry. Error Code: AccountNotInitialized.` - ) - }) + it('claims the stale proposal', async () => { + const proposalBefore = await program.account.proposal.fetch(proposalKey) + const proposalBalanceBefore = Number(provider.client.getBalance(proposalKey)) || 0 + const proposalCreatorBalanceBefore = Number(provider.client.getBalance(proposalBefore.creator)) || 0 - it('cannot add signature for non-existent intent', async () => { - const intentHash = generateIntentHash() - const intentKey = sdk.getIntentKey(intentHash) + const ix = await solverSdk.claimStaleProposalIx(intentHash) + await makeTxSignAndSend(solverProvider, ix) - const signature = await createValidatorSignature(intentHash, whitelistedValidator) + const proposalBalanceAfter = Number(provider.client.getBalance(proposalKey)) || 0 + const proposalCreatorBalanceAfter = Number(provider.client.getBalance(proposalBefore.creator)) || 0 - const ed25519Ix = Ed25519Program.createInstructionWithPublicKey({ - message: Buffer.from(intentHash, 'hex'), - publicKey: whitelistedValidator.publicKey.toBuffer(), - signature: Buffer.from(signature), - }) + try { + await program.account.proposal.fetch(proposalKey) + expect.fail('Proposal account should be closed') + } catch (error: any) { + expect(error.message).to.include(`Account does not exist`) + } - const ix = await program.methods - .addValidatorSig() - .accountsPartial({ - solver: solverSdk.getSignerKey(), - solverRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Solver, solverSdk.getSignerKey()), - intent: intentKey, - fulfilledIntent: solverSdk.getFulfilledIntentKey(intentHash), - validatorRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Validator, whitelistedValidator.publicKey), - ixSysvar: SYSVAR_INSTRUCTIONS_PUBKEY, + expect(proposalCreatorBalanceAfter).to.be.eq( + proposalCreatorBalanceBefore + proposalBalanceBefore - ACCOUNT_CLOSE_FEE + ) + expect(proposalBalanceAfter).to.be.eq(0) }) - .instruction() - - const res = await makeTxSignAndSend(solverProvider, ed25519Ix, ix) - expectTransactionError(res, `AccountNotInitialized`) - }) + it('cannot claim the stale proposal again', async () => { + client.expireBlockhash() + const ix = await solverSdk.claimStaleProposalIx(intentHash) + const res = await makeTxSignAndSend(solverProvider, ix) - it('cannot add signature if fulfilled_intent PDA already exists', async () => { - const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey = sdk.getIntentKey(intentHash) - - const fulfilledIntent = sdk.getFulfilledIntentKey(intentHash) - client.setAccount(fulfilledIntent, { - executable: false, - lamports: 1002240, - owner: program.programId, - data: Buffer.from('595168911b9267f7' + '010000000000000000', 'hex'), + expectTransactionError(res, 'AnchorError caused by account: proposal. Error Code: AccountNotInitialized') + }) }) - const signature = await createValidatorSignature(intentHash, whitelistedValidator) + context('when proposal is not stale', () => { + context('when deadline has not passed', () => { + beforeEach('create proposal and warp time', async () => { + intentHash = await createTestProposal({ + proposalParams: { deadline: getCurrentTimestamp(client, LONG_DEADLINE) }, + }) + warpSeconds(provider, WARP_TIME_SHORT) + }) - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) + itThrowsAnError('Proposal not yet expired') + }) - 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` - ) - }) + context('when deadline equals now', () => { + beforeEach('create proposal and warp time', async () => { + intentHash = await createTestProposal({ + proposalParams: { deadline: getCurrentTimestamp(client, SHORT_DEADLINE) }, + }) + warpSeconds(provider, WARP_TIME_SHORT) + }) - it('cannot add signature with wrong intent hash', async () => { - const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, + itThrowsAnError('Proposal not yet expired') + }) }) - const intentKey = sdk.getIntentKey(intentHash) - - const wrongIntentHash = generateIntentHash() - const signature = await createValidatorSignature(wrongIntentHash, whitelistedValidator) - - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(wrongIntentHash, 'hex'), - whitelistedValidator.publicKey, - signature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `Signature verification failed`) }) - it('cannot add signature with invalid signature', async () => { - const intentHash = await createValidatedIntent(solverSdk, solverProvider, client, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, + context('when proposal does not exist', () => { + beforeEach('generate non-existent intent hash', () => { + intentHash = generateIntentHash() }) - const intentKey = sdk.getIntentKey(intentHash) - - const invalidSignature: number[] = Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)) - const ixs = await solverSdk.addValidatorSigIxs( - intentKey, - Buffer.from(intentHash, 'hex'), - whitelistedValidator.publicKey, - invalidSignature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expect(res.toString()).to.be.eq( - `FailedTransactionMetadata(FailedTransactionMetadata { err: InstructionError(0, Custom(2)), meta: TransactionMetadata { signature: 1111111111111111111111111111111111111111111111111111111111111111, logs: [], inner_instructions: [], compute_units_consumed: 0, return_data: TransactionReturnData { program_id: 11111111111111111111111111111111, data: [] } } })` - ) - }) - - it('cannot add valid signature but for another intent', async () => { - const intentHash1 = await createTestIntent(solverSdk, solverProvider, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - const intentKey1 = sdk.getIntentKey(intentHash1) - - const intentHash2 = await createTestIntent(solverSdk, solverProvider, { - minValidations: DEFAULT_MIN_VALIDATIONS, - isFinal: true, - }) - - const signature = await createValidatorSignature(intentHash2, whitelistedValidator) - - const ixs = await solverSdk.addValidatorSigIxs( - intentKey1, - Buffer.from(intentHash2, 'hex'), - whitelistedValidator.publicKey, - signature - ) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `Signature verification failed`) + itThrowsAnError('AnchorError caused by account: proposal. Error Code: AccountNotInitialized') }) }) - describe('add_axia_sig', () => { - let whitelistedAxia: Keypair - - before(async () => { - whitelistedAxia = Keypair.generate() - const whitelistAxiaIx = await controllerSdk.setAllowedEntityIx(EntityType.Axia, whitelistedAxia.publicKey) - await makeTxSignAndSend(provider, whitelistAxiaIx) - }) - - it('should add axia signature successfully', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const proposalBefore = await program.account.proposal.fetch(proposalKey) - expect(proposalBefore.isSigned).to.be.false - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs) - - const proposalAfter = await program.account.proposal.fetch(proposalKey) - expect(proposalAfter.isSigned).to.be.true - }) - - it('should handle duplicate signature gracefully', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs1 = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs1) - - const proposalAfter1 = await program.account.proposal.fetch(proposalKey) - expect(proposalAfter1.isSigned).to.be.true - - client.expireBlockhash() - const ixs2 = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs2) - - const proposalAfter2 = await program.account.proposal.fetch(proposalKey) - expect(proposalAfter2.isSigned).to.be.true - }) - - it('should add signature multiple times safely', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs1 = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs1) - - client.expireBlockhash() - const ixs2 = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs2) - - client.expireBlockhash() - const ixs3 = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs3) - - const proposalAfter = await program.account.proposal.fetch(proposalKey) - expect(proposalAfter.isSigned).to.be.true - }) - - it('cannot add signature if proposal is not final', 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, false) - await makeTxSignAndSend(solverProvider, ix) - - const proposalKey = sdk.getProposalKey(intentHash, solver.publicKey) - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, 'Proposal is not final') - }) - - it('cannot add signature if proposal has expired', async () => { - const now = Number(client.getClock().unixTimestamp) - const deadline = now + STALE_CLAIM_DELAY - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program, { deadline }) - - warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, 'Proposal has already expired') - }) - - it('cannot add signature if axia is not whitelisted', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const axia = Keypair.generate() - const signature = await createAxiaSignature(proposalKey, axia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, axia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `AnchorError caused by account: axia_registry. Error Code: AccountNotInitialized.`) - }) - - it('cannot add signature if solver is not whitelisted', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs = await maliciousSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - const res = await makeTxSignAndSend(maliciousProvider, ...ixs) - - expectTransactionError( - res, - `AnchorError caused by account: solver_registry. Error Code: AccountNotInitialized.` - ) - }) - - it('cannot add signature for non-existent proposal', async () => { - const proposalKey = Keypair.generate().publicKey - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ed25519Ix = Ed25519Program.createInstructionWithPublicKey({ - message: proposalKey.toBuffer(), - publicKey: whitelistedAxia.publicKey.toBuffer(), - signature: Buffer.from(signature), - }) - - const ix = await program.methods - .addAxiaSig() - .accountsPartial({ - solver: solverSdk.getSignerKey(), - solverRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Solver, solverSdk.getSignerKey()), - proposal: proposalKey, - axiaRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Axia, whitelistedAxia.publicKey), - ixSysvar: SYSVAR_INSTRUCTIONS_PUBKEY, - }) - .instruction() - - const res = await makeTxSignAndSend(solverProvider, ed25519Ix, ix) - - expectTransactionError( - res, - `Program log: AnchorError caused by account: proposal. Error Code: AccountNotInitialized` - ) - }) - - it('cannot add signature if proposal deadline equals now', async () => { - const now = Number(client.getClock().unixTimestamp) - const deadline = now + SHORT_DEADLINE - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program, { deadline }) - - warpSeconds(provider, WARP_TIME_SHORT) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, 'Proposal has already expired') - }) - - it('cannot add signature with wrong proposal key', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - const wrongProposalKey = Keypair.generate().publicKey - - const signature = await createAxiaSignature(wrongProposalKey, whitelistedAxia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expect(res.toString()).to.be.eq( - `FailedTransactionMetadata(FailedTransactionMetadata { err: InstructionError(0, Custom(2)), meta: TransactionMetadata { signature: 1111111111111111111111111111111111111111111111111111111111111111, logs: [], inner_instructions: [], compute_units_consumed: 0, return_data: TransactionReturnData { program_id: 11111111111111111111111111111111, data: [] } } })` - ) - }) - - it('cannot add signature with invalid signature', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const invalidSignature: number[] = Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, invalidSignature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expect(res.toString()).to.be.eq( - `FailedTransactionMetadata(FailedTransactionMetadata { err: InstructionError(0, Custom(2)), meta: TransactionMetadata { signature: 1111111111111111111111111111111111111111111111111111111111111111, logs: [], inner_instructions: [], compute_units_consumed: 0, return_data: TransactionReturnData { program_id: 11111111111111111111111111111111, data: [] } } })` - ) - }) - - it('cannot add signature from wrong axia pubkey', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const wrongAxia = Keypair.generate() - const signature = await createAxiaSignature(proposalKey, wrongAxia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, wrongAxia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `AnchorError caused by account: axia_registry. Error Code: AccountNotInitialized.`) - }) - - it('cannot add signature with signature from different axia', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const axia2 = await createAllowlistedEntity(controllerSdk, provider, EntityType.Axia) - const signature = await createAxiaSignature(proposalKey, axia2) - - // Try to use axia2's signature but claim it's from whitelistedAxia - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expect(res.toString()).to.be.eq( - `FailedTransactionMetadata(FailedTransactionMetadata { err: InstructionError(0, Custom(2)), meta: TransactionMetadata { signature: 1111111111111111111111111111111111111111111111111111111111111111, logs: [], inner_instructions: [], compute_units_consumed: 0, return_data: TransactionReturnData { program_id: 11111111111111111111111111111111, data: [] } } })` - ) - }) - - it('cannot add signature with signature from validator instead of axia', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - const validator = await createAllowlistedEntity(controllerSdk, provider, EntityType.Validator) - const signature = await createAxiaSignature(proposalKey, validator) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, validator.publicKey, signature) - const res = await makeTxSignAndSend(solverProvider, ...ixs) - - expectTransactionError(res, `AnchorError caused by account: axia_registry. Error Code: AccountNotInitialized.`) - }) - - it('cannot add signature if signed message is wrong', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - // Sign a different message (e.g., intent hash instead of proposal key) - const intentHash = generateIntentHash() - const signature = await signAsync(Buffer.from(intentHash, 'hex'), whitelistedAxia.secretKey.slice(0, 32)) - const signatureArray = Array.from(new Uint8Array(signature)) - - const ed25519Ix = Ed25519Program.createInstructionWithPublicKey({ - message: Buffer.from(intentHash, 'hex'), - publicKey: whitelistedAxia.publicKey.toBuffer(), - signature: Buffer.from(signatureArray), - }) - - const ix = await program.methods - .addAxiaSig() - .accountsPartial({ - solver: solverSdk.getSignerKey(), - solverRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Solver, solverSdk.getSignerKey()), - proposal: proposalKey, - axiaRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Axia, whitelistedAxia.publicKey), - ixSysvar: SYSVAR_INSTRUCTIONS_PUBKEY, - }) - .instruction() - - const res = await makeTxSignAndSend(solverProvider, ed25519Ix, ix) - - expectTransactionError(res, `Signature verification failed`) - }) - - it('cannot add signature with corrupted ed25519 instruction', async () => { - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program) - - // Create corrupted Ed25519 instruction with wrong program ID - const corruptedEd25519Ix = new TransactionInstruction({ - programId: Keypair.generate().publicKey, - keys: [], - data: Buffer.from([ - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, - ]), + context('when caller is not proposal creator', () => { + beforeEach('create proposal and warp time', async () => { + intentHash = await createTestProposal({ + proposalParams: { deadline: getCurrentTimestamp(client, STALE_CLAIM_DELAY) }, }) - - const ix = await program.methods - .addAxiaSig() - .accountsPartial({ - solver: solverSdk.getSignerKey(), - solverRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Solver, solverSdk.getSignerKey()), - proposal: proposalKey, - axiaRegistry: solverSdk.getEntityRegistryPubkey(EntityType.Axia, whitelistedAxia.publicKey), - ixSysvar: SYSVAR_INSTRUCTIONS_PUBKEY, - }) - .instruction() - - const res = await makeTxSignAndSend(solverProvider, corruptedEd25519Ix, ix) - - expect(res.toString()).to.be.eq( - // eslint-disable-next-line no-secrets/no-secrets - `FailedTransactionMetadata(FailedTransactionMetadata { err: InvalidProgramForExecution, meta: TransactionMetadata { signature: 1111111111111111111111111111111111111111111111111111111111111111, logs: [], inner_instructions: [], compute_units_consumed: 0, return_data: TransactionReturnData { program_id: 11111111111111111111111111111111, data: [] } } })` - ) + warpSeconds(provider, STALE_CLAIM_DELAY_PLUS_ONE) }) - it('should add signature when proposal deadline is close', async () => { - const now = Number(client.getClock().unixTimestamp) - const deadline = now + VERY_SHORT_DEADLINE - const { proposalKey } = await createFinalizedProposal(solverSdk, solverProvider, client, program, { deadline }) - - const signature = await createAxiaSignature(proposalKey, whitelistedAxia) - - const ixs = await solverSdk.addAxiaSigIxs(proposalKey, whitelistedAxia.publicKey, signature) - await makeTxSignAndSend(solverProvider, ...ixs) + it('throws an error', async () => { + const ix = await maliciousSdk.claimStaleProposalIx(intentHash, solver.publicKey) + const res = await makeTxSignAndSend(maliciousProvider, ix) - const proposalAfter = await program.account.proposal.fetch(proposalKey) - expect(proposalAfter.isSigned).to.be.true + expectTransactionError(res, `Signer must be proposal creator`) }) }) })