diff --git a/Makefile b/Makefile index 07f97109..34177432 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,8 @@ ci-integration-test-prebuilt: @cd integration && cargo test --test mint_idempotency_integration -- --nocapture @cd integration && cargo test --test gap_detection_integration -- --nocapture @cd integration && cargo test --test truncate_integration -- --nocapture + @cd integration && cargo test --test pausable_mint_integration -- --nocapture + @cd integration && cargo test --test permanent_delegate_mint_integration -- --nocapture @cd integration && cargo test --test resync_integration -- --nocapture @cd integration && cargo test --test reconciliation_e2e_test -- --nocapture @cd integration && cargo test --test mock_rpc_retry -- --nocapture @@ -157,6 +159,8 @@ ci-integration-test-indexer: @cd integration && cargo test --test mint_idempotency_integration -- --nocapture @cd integration && cargo test --test gap_detection_integration -- --nocapture @cd integration && cargo test --test truncate_integration -- --nocapture + @cd integration && cargo test --test pausable_mint_integration -- --nocapture + @cd integration && cargo test --test permanent_delegate_mint_integration -- --nocapture @cd integration && cargo test --test resync_integration -- --nocapture @cd integration && cargo test --test reconciliation_e2e_test -- --nocapture @cd integration && cargo test --test mock_rpc_retry -- --nocapture diff --git a/contra-escrow-program/TEST_COVERAGE.md b/contra-escrow-program/TEST_COVERAGE.md index 6c0a4b89..9af64d77 100644 --- a/contra-escrow-program/TEST_COVERAGE.md +++ b/contra-escrow-program/TEST_COVERAGE.md @@ -38,8 +38,9 @@ - `test_allow_mint_invalid_admin` — wrong admin rejected - `test_allow_mint_invalid_instance_account_owner` — wrong owner rejected - `test_allow_mint_token_2022_basic_success` — Token2022 mint allowed -- `test_allow_mint_token_2022_permanent_delegate_blocked` — PermanentDelegateNotAllowed -- `test_allow_mint_token_2022_pausable_blocked` — PausableMintNotAllowed +- `test_allow_mint_token_2022_permanent_delegate_accepted` — permanent-delegate Token-2022 mint allowed; drain detection is enforced by the operator at withdrawal time +- `test_allow_mint_token_2022_pausable_accepted` — pausable Token-2022 mint allowed; pause state is enforced by the operator at withdrawal time +- `test_allow_mint_token_2022_transfer_hook_blocked` — TransferHookNotAllowed; the program's `TransferChecked` CPI does not resolve extra-account metas, so hook mints are rejected at validation ### BlockMint (9 integration tests) @@ -90,7 +91,7 @@ - `test_deposit_invalid_instruction_data_too_short` — malformed data - `test_deposit_not_enough_accounts` — missing accounts - `test_deposit_token_2022_basic_success` — Token2022 deposit -- `test_deposit_token_2022_permanent_delegate_rejected` — Token2022 extension blocked +- `test_deposit_token_2022_transfer_hook_rejected` — TransferHookNotAllowed on deposit path (live swap of mint data post-AllowMint proves the check runs at deposit, not only at AllowMint) - `test_deposit_invalid_associated_token_program` — wrong ATA program rejected - `test_multiple_depositors_same_instance` — three users deposit to same instance - `test_deposit_wrong_user_ata` — passing another user's ATA as the user_ata is rejected with InvalidInstructionData diff --git a/contra-escrow-program/idl/contra_escrow_program.json b/contra-escrow-program/idl/contra_escrow_program.json index 3c9f0a1f..b248394b 100644 --- a/contra-escrow-program/idl/contra_escrow_program.json +++ b/contra-escrow-program/idl/contra_escrow_program.json @@ -514,47 +514,41 @@ { "code": 6, "kind": "errorNode", - "message": "Permanent delegate extension not allowed", - "name": "permanentDelegateNotAllowed" + "message": "Transfer hook extension not allowed", + "name": "transferHookNotAllowed" }, { "code": 7, "kind": "errorNode", - "message": "Pausable mint extension not allowed", - "name": "pausableMintNotAllowed" - }, - { - "code": 8, - "kind": "errorNode", "message": "Invalid operator PDA provided", "name": "invalidOperatorPda" }, { - "code": 9, + "code": 8, "kind": "errorNode", "message": "Invalid token account provided", "name": "invalidTokenAccount" }, { - "code": 10, + "code": 9, "kind": "errorNode", "message": "Invalid escrow balance", "name": "invalidEscrowBalance" }, { - "code": 11, + "code": 10, "kind": "errorNode", "message": "Invalid allowed mint", "name": "invalidAllowedMint" }, { - "code": 12, + "code": 11, "kind": "errorNode", "message": "Invalid SMT proof provided", "name": "invalidSmtProof" }, { - "code": 13, + "code": 12, "kind": "errorNode", "message": "Invalid transaction nonce for current tree index", "name": "invalidTransactionNonceForCurrentTreeIndex" diff --git a/contra-escrow-program/program/src/error.rs b/contra-escrow-program/program/src/error.rs index 5658e7f3..2642835b 100644 --- a/contra-escrow-program/program/src/error.rs +++ b/contra-escrow-program/program/src/error.rs @@ -29,35 +29,31 @@ pub enum ContraEscrowProgramError { #[error("Invalid admin provided")] InvalidAdmin, - /// (6) Permanent delegate extension not allowed - #[error("Permanent delegate extension not allowed")] - PermanentDelegateNotAllowed, + /// (6) Transfer hook extension not allowed + #[error("Transfer hook extension not allowed")] + TransferHookNotAllowed, - /// (7) Pausable mint extension not allowed - #[error("Pausable mint extension not allowed")] - PausableMintNotAllowed, - - /// (8) Invalid operator PDA provided + /// (7) Invalid operator PDA provided #[error("Invalid operator PDA provided")] InvalidOperatorPda, - /// (9) Invalid token account provided + /// (8) Invalid token account provided #[error("Invalid token account provided")] InvalidTokenAccount, - /// (10) Invalid escrow balance + /// (9) Invalid escrow balance #[error("Invalid escrow balance")] InvalidEscrowBalance, - /// (11) Invalid allowed mint + /// (10) Invalid allowed mint #[error("Invalid allowed mint")] InvalidAllowedMint, - /// (12) Invalid SMT proof provided + /// (11) Invalid SMT proof provided #[error("Invalid SMT proof provided")] InvalidSmtProof, - /// (13) Invalid transaction nonce for current tree index + /// (12) Invalid transaction nonce for current tree index #[error("Invalid transaction nonce for current tree index")] InvalidTransactionNonceForCurrentTreeIndex, } @@ -87,14 +83,13 @@ mod tests { (InvalidInstanceId, 3), (InvalidInstance, 4), (InvalidAdmin, 5), - (PermanentDelegateNotAllowed, 6), - (PausableMintNotAllowed, 7), - (InvalidOperatorPda, 8), - (InvalidTokenAccount, 9), - (InvalidEscrowBalance, 10), - (InvalidAllowedMint, 11), - (InvalidSmtProof, 12), - (InvalidTransactionNonceForCurrentTreeIndex, 13), + (TransferHookNotAllowed, 6), + (InvalidOperatorPda, 7), + (InvalidTokenAccount, 8), + (InvalidEscrowBalance, 9), + (InvalidAllowedMint, 10), + (InvalidSmtProof, 11), + (InvalidTransactionNonceForCurrentTreeIndex, 12), ]; for (error, expected_code) in cases { diff --git a/contra-escrow-program/program/src/processor/shared/token_utils.rs b/contra-escrow-program/program/src/processor/shared/token_utils.rs index 9a81bb83..0b770254 100644 --- a/contra-escrow-program/program/src/processor/shared/token_utils.rs +++ b/contra-escrow-program/program/src/processor/shared/token_utils.rs @@ -8,9 +8,8 @@ use pinocchio_token_2022::{ state::Mint as Token2022Mint, state::TokenAccount as Token2022Account, ID as TOKEN_2022_PROGRAM_ID, }; -use spl_token_2022::extension::StateWithExtensions; use spl_token_2022::extension::{ - pausable::PausableConfig, permanent_delegate::PermanentDelegate, BaseStateWithExtensions, + transfer_hook::TransferHook, BaseStateWithExtensions, StateWithExtensions, }; use spl_token_2022::state::Mint as Token2022MintState; @@ -111,7 +110,18 @@ pub fn get_mint_decimals(mint_info: &AccountView) -> Result { Err(ContraEscrowProgramError::InvalidMint.into()) } -/// Blocks mints with PermanentDelegate or Pausable extensions. +/// Validates the mint's data parses as a Token-2022 mint and rejects +/// mints carrying the `TransferHook` extension. +/// +/// Pausable and permanent-delegate mints are accepted — the operator +/// enforces pause and drain states off-chain via a withdrawal pre-flight. +/// `TransferHook` is a different class of problem: honoring the hook would +/// require the program to resolve `ExtraAccountMetaList` PDAs and forward +/// them through the `TransferChecked` CPI, which the current pinocchio +/// builder does not do. Accepting such a mint would cause every deposit / +/// release to fail on-chain (Token-2022 invokes the hook program without +/// the required extra accounts), so we reject it at the validation seam +/// rather than let downstream transfers burn fees. #[inline(always)] pub fn validate_token2022_extensions(mint_info: &AccountView) -> ProgramResult { let data = mint_info.try_borrow()?; @@ -119,11 +129,8 @@ pub fn validate_token2022_extensions(mint_info: &AccountView) -> ProgramResult { let mint = StateWithExtensions::::unpack(&data) .map_err(|_| ContraEscrowProgramError::InvalidMint)?; - if mint.get_extension::().is_ok() { - return Err(ContraEscrowProgramError::PermanentDelegateNotAllowed.into()); - } - if mint.get_extension::().is_ok() { - return Err(ContraEscrowProgramError::PausableMintNotAllowed.into()); + if mint.get_extension::().is_ok() { + return Err(ContraEscrowProgramError::TransferHookNotAllowed.into()); } Ok(()) diff --git a/contra-escrow-program/tests/integration-tests/src/test_allow_mint/mod.rs b/contra-escrow-program/tests/integration-tests/src/test_allow_mint/mod.rs index d55a6dd8..6176d679 100644 --- a/contra-escrow-program/tests/integration-tests/src/test_allow_mint/mod.rs +++ b/contra-escrow-program/tests/integration-tests/src/test_allow_mint/mod.rs @@ -3,10 +3,10 @@ use crate::{ state_utils::{assert_get_or_allow_mint, assert_get_or_create_instance}, utils::{ assert_program_error, set_mint, set_mint_2022_basic, set_mint_2022_with_pausable, - set_mint_2022_with_permanent_delegate, TestContext, CONTRA_ESCROW_PROGRAM_ID, - INVALID_ACCOUNT_DATA_ERROR, INVALID_ADMIN_ERROR, INVALID_ALLOWED_MINT_ERROR, - MISSING_REQUIRED_SIGNATURE_ERROR, PAUSABLE_MINT_NOT_ALLOWED_ERROR, - PERMANENT_DELEGATE_NOT_ALLOWED_ERROR, TOKEN_2022_PROGRAM_ID, + set_mint_2022_with_permanent_delegate, set_mint_2022_with_transfer_hook, TestContext, + CONTRA_ESCROW_PROGRAM_ID, INVALID_ACCOUNT_DATA_ERROR, INVALID_ADMIN_ERROR, + INVALID_ALLOWED_MINT_ERROR, MISSING_REQUIRED_SIGNATURE_ERROR, TOKEN_2022_PROGRAM_ID, + TRANSFER_HOOK_NOT_ALLOWED_ERROR, }, }; use contra_escrow_program_client::instructions::AllowMintBuilder; @@ -321,7 +321,7 @@ fn test_allow_mint_token_2022_basic_success() { } #[test] -fn test_allow_mint_token_2022_permanent_delegate_blocked() { +fn test_allow_mint_token_2022_permanent_delegate_accepted() { let mut context = TestContext::new(); let admin = Keypair::new(); let mint = Keypair::new(); @@ -334,48 +334,55 @@ fn test_allow_mint_token_2022_permanent_delegate_blocked() { assert_get_or_create_instance(&mut context, &admin, &instance_seed, false, false) .expect("CreateInstance should succeed"); - context - .airdrop_if_required(&admin.pubkey(), 1_000_000_000) - .unwrap(); - - let (allowed_mint_pda, bump) = find_allowed_mint_pda(&instance_pda, &mint.pubkey()); - let (event_authority_pda, _) = find_event_authority_pda(); - let instance_ata = spl_associated_token_account::get_associated_token_address_with_program_id( + assert_get_or_allow_mint( + &mut context, + &admin, &instance_pda, &mint.pubkey(), - &TOKEN_2022_PROGRAM_ID, - ); + false, + true, + ) + .expect("AllowMint should succeed for a Token-2022 mint with a permanent delegate"); +} - let instruction = AllowMintBuilder::new() - .payer(context.payer.pubkey()) - .admin(admin.pubkey()) - .instance(instance_pda) - .mint(mint.pubkey()) - .allowed_mint(allowed_mint_pda) - .instance_ata(instance_ata) - .system_program(SYSTEM_PROGRAM_ID) - .token_program(TOKEN_2022_PROGRAM_ID) - .associated_token_program(spl_associated_token_account::ID) - .event_authority(event_authority_pda) - .contra_escrow_program(CONTRA_ESCROW_PROGRAM_ID) - .bump(bump) - .instruction(); +#[test] +fn test_allow_mint_token_2022_pausable_accepted() { + let mut context = TestContext::new(); + let admin = Keypair::new(); + let mint = Keypair::new(); + let authority = Keypair::new(); - let result = context.send_transaction_with_signers(instruction, &[&admin]); + let instance_seed = Keypair::new(); + + set_mint_2022_with_pausable(&mut context, &mint.pubkey(), &authority.pubkey()); + + let (instance_pda, _) = + assert_get_or_create_instance(&mut context, &admin, &instance_seed, false, false) + .expect("CreateInstance should succeed"); - assert_program_error(result, PERMANENT_DELEGATE_NOT_ALLOWED_ERROR); + assert_get_or_allow_mint( + &mut context, + &admin, + &instance_pda, + &mint.pubkey(), + false, + true, + ) + .expect("AllowMint should succeed for a pausable Token-2022 mint"); } #[test] -fn test_allow_mint_token_2022_pausable_blocked() { +fn test_allow_mint_token_2022_transfer_hook_blocked() { let mut context = TestContext::new(); let admin = Keypair::new(); let mint = Keypair::new(); - let authority = Keypair::new(); + // Hook program id is arbitrary — the check fires on the extension's + // presence, not on whether the hook program exists on-chain. + let hook_program_id = Pubkey::new_unique(); let instance_seed = Keypair::new(); - set_mint_2022_with_pausable(&mut context, &mint.pubkey(), &authority.pubkey()); + set_mint_2022_with_transfer_hook(&mut context, &mint.pubkey(), &hook_program_id); let (instance_pda, _) = assert_get_or_create_instance(&mut context, &admin, &instance_seed, false, false) @@ -410,5 +417,5 @@ fn test_allow_mint_token_2022_pausable_blocked() { let result = context.send_transaction_with_signers(instruction, &[&admin]); - assert_program_error(result, PAUSABLE_MINT_NOT_ALLOWED_ERROR); + assert_program_error(result, TRANSFER_HOOK_NOT_ALLOWED_ERROR); } diff --git a/contra-escrow-program/tests/integration-tests/src/test_deposit/mod.rs b/contra-escrow-program/tests/integration-tests/src/test_deposit/mod.rs index 814a4224..51f8f57c 100644 --- a/contra-escrow-program/tests/integration-tests/src/test_deposit/mod.rs +++ b/contra-escrow-program/tests/integration-tests/src/test_deposit/mod.rs @@ -4,12 +4,11 @@ use crate::{ utils::{ assert_program_error, create_mint_2022_with_transfer_fee, get_or_create_associated_token_account, get_or_create_associated_token_account_2022, - get_token_balance, set_mint, set_mint_2022_basic, set_mint_2022_with_permanent_delegate, + get_token_balance, set_mint, set_mint_2022_basic, set_mint_2022_with_transfer_hook, set_token_balance, setup_test_balances, TestContext, ATA_PROGRAM_ID, CONTRA_ESCROW_PROGRAM_ID, INCORRECT_PROGRAM_ID_ERROR, INVALID_ACCOUNT_DATA_ERROR, - INVALID_INSTRUCTION_DATA_ERROR, NOT_ENOUGH_ACCOUNT_KEYS_ERROR, - PERMANENT_DELEGATE_NOT_ALLOWED_ERROR, TOKEN_2022_PROGRAM_ID, - TOKEN_INSUFFICIENT_FUNDS_ERROR, + INVALID_INSTRUCTION_DATA_ERROR, NOT_ENOUGH_ACCOUNT_KEYS_ERROR, TOKEN_2022_PROGRAM_ID, + TOKEN_INSUFFICIENT_FUNDS_ERROR, TRANSFER_HOOK_NOT_ALLOWED_ERROR, }, }; @@ -454,8 +453,17 @@ fn test_deposit_token_2022_transfer_fee_success() { ); } +// `validate_token2022_extensions` runs on the deposit path as well as AllowMint +// (deposit.rs:100, allow_mint.rs:70). Without a deposit-side test, a future +// refactor that moves the check out of `validate_token2022_extensions` for +// just one path would pass CI. Test strategy mirrors the old +// `test_deposit_token_2022_permanent_delegate_rejected`: stand up a clean +// Token-2022 mint, AllowMint it (check passes), prime the user's balance, +// then swap the mint account data for a TransferHook mint via the litesvm +// cheat code. The deposit must then fail with TransferHookNotAllowed — proving +// the check is live on the deposit path, independent of AllowMint. #[test] -fn test_deposit_token_2022_permanent_delegate_rejected() { +fn test_deposit_token_2022_transfer_hook_rejected() { let mut context = TestContext::new(); let admin = Keypair::new(); let user = Keypair::new(); @@ -464,10 +472,9 @@ fn test_deposit_token_2022_permanent_delegate_rejected() { let instance_seed = Keypair::new(); - // Step 1: Create a normal Token2022 mint without permanent delegate + // 1. Clean Token-2022 mint (no extensions) — passes AllowMint. set_mint_2022_basic(&mut context, &good_mint.pubkey()); - // Step 2: Create instance and allow the good mint let (instance_pda, _) = assert_get_or_create_instance(&mut context, &admin, &instance_seed, false, false) .expect("CreateInstance should succeed"); @@ -482,7 +489,6 @@ fn test_deposit_token_2022_permanent_delegate_rejected() { ) .expect("AllowMint should succeed for normal mint"); - // Step 3: Set up deposit test with good mint setup_test_balances( &mut context, &user, @@ -493,22 +499,23 @@ fn test_deposit_token_2022_permanent_delegate_rejected() { 0, ); - // Step 4: Create a bad mint with permanent delegate extension (we only need its account data) - set_mint_2022_with_permanent_delegate(&mut context, &bad_mint.pubkey()); + // 2. Build a separate mint account with the TransferHook extension + // initialized — we only need its account data. + let hook_program_id = Keypair::new().pubkey(); + set_mint_2022_with_transfer_hook(&mut context, &bad_mint.pubkey(), &hook_program_id); - // Step 5: Use LiteSVM cheat code to replace the good mint's account data with bad mint data - // This simulates a scenario where a legitimate mint gets compromised with permanent delegate + // 3. litesvm cheat: overwrite the good mint's account with the bad mint's + // data. AllowMint has already landed, but the deposit handler re-runs + // `validate_token2022_extensions` against the live mint account. let bad_mint_account = context .get_account(&bad_mint.pubkey()) .expect("Bad mint account should exist"); - - // Replace the good mint account with bad mint account data (which has permanent delegate) context .svm .set_account(good_mint.pubkey(), bad_mint_account) - .expect("Failed to set good mint account with bad mint data"); + .expect("Failed to overwrite good mint with TransferHook mint data"); - // Step 6: Try to deposit - should fail because good_mint now has permanent delegate data + // 4. Attempt to deposit — the deposit-side validation must reject. context .airdrop_if_required(&user.pubkey(), 1_000_000_000) .unwrap(); @@ -516,12 +523,12 @@ fn test_deposit_token_2022_permanent_delegate_rejected() { let user_ata = get_associated_token_address_with_program_id( &user.pubkey(), - &good_mint.pubkey(), // Use good mint (the one we originally set up) + &good_mint.pubkey(), &TOKEN_2022_PROGRAM_ID, ); let instance_ata = get_associated_token_address_with_program_id( &instance_pda, - &good_mint.pubkey(), // Use good mint (the one we originally set up) + &good_mint.pubkey(), &TOKEN_2022_PROGRAM_ID, ); @@ -529,8 +536,8 @@ fn test_deposit_token_2022_permanent_delegate_rejected() { .payer(context.payer.pubkey()) .user(user.pubkey()) .instance(instance_pda) - .mint(good_mint.pubkey()) // Use good mint (but it now has bad mint data) - .allowed_mint(allowed_mint_pda) // AllowedMint for good mint + .mint(good_mint.pubkey()) + .allowed_mint(allowed_mint_pda) .user_ata(user_ata) .instance_ata(instance_ata) .system_program(SYSTEM_PROGRAM_ID) @@ -543,7 +550,7 @@ fn test_deposit_token_2022_permanent_delegate_rejected() { let result = context.send_transaction_with_signers(instruction, &[&user]); - assert_program_error(result, PERMANENT_DELEGATE_NOT_ALLOWED_ERROR); + assert_program_error(result, TRANSFER_HOOK_NOT_ALLOWED_ERROR); } #[test] diff --git a/contra-escrow-program/tests/integration-tests/src/utils.rs b/contra-escrow-program/tests/integration-tests/src/utils.rs index c587e0d5..f37c1ba7 100644 --- a/contra-escrow-program/tests/integration-tests/src/utils.rs +++ b/contra-escrow-program/tests/integration-tests/src/utils.rs @@ -19,8 +19,8 @@ use spl_token::{ use spl_token_2022::{ extension::{ pausable::PausableConfig, permanent_delegate::PermanentDelegate, - transfer_fee::instruction::initialize_transfer_fee_config, BaseStateWithExtensionsMut, - ExtensionType, + transfer_fee::instruction::initialize_transfer_fee_config, transfer_hook::TransferHook, + BaseStateWithExtensionsMut, ExtensionType, }, state::Mint as Token2022Mint, }; @@ -47,13 +47,11 @@ pub const INVALID_ATA_ERROR: u32 = ContraEscrowProgramError::InvalidAta as u32; pub const INVALID_MINT_ERROR: u32 = ContraEscrowProgramError::InvalidMint as u32; pub const INVALID_INSTANCE_ERROR: u32 = ContraEscrowProgramError::InvalidInstance as u32; pub const INVALID_ADMIN_ERROR: u32 = ContraEscrowProgramError::InvalidAdmin as u32; -pub const PERMANENT_DELEGATE_NOT_ALLOWED_ERROR: u32 = - ContraEscrowProgramError::PermanentDelegateNotAllowed as u32; -pub const PAUSABLE_MINT_NOT_ALLOWED_ERROR: u32 = - ContraEscrowProgramError::PausableMintNotAllowed as u32; pub const INVALID_ALLOWED_MINT_ERROR: u32 = ContraEscrowProgramError::InvalidAllowedMint as u32; pub const INVALID_OPERATOR_ERROR: u32 = ContraEscrowProgramError::InvalidOperatorPda as u32; pub const INVALID_SMT_PROOF_ERROR: u32 = ContraEscrowProgramError::InvalidSmtProof as u32; +pub const TRANSFER_HOOK_NOT_ALLOWED_ERROR: u32 = + ContraEscrowProgramError::TransferHookNotAllowed as u32; pub const INVALID_TRANSACTION_NONCE_FOR_CURRENT_TREE_INDEX_ERROR: u32 = ContraEscrowProgramError::InvalidTransactionNonceForCurrentTreeIndex as u32; @@ -596,6 +594,51 @@ pub fn set_mint_2022_with_pausable(context: &mut TestContext, mint: &Pubkey, aut .expect("Failed to set Token 2022 mint account with Pausable"); } +pub fn set_mint_2022_with_transfer_hook( + context: &mut TestContext, + mint: &Pubkey, + hook_program_id: &Pubkey, +) { + let extensions = [ExtensionType::TransferHook]; + let space = ExtensionType::try_calculate_account_len::(&extensions).unwrap(); + let mut data = vec![0u8; space]; + + let mut state = PodStateWithExtensionsMut::::unpack_uninitialized(&mut data).unwrap(); + + let transfer_hook = state.init_extension::(true).unwrap(); + *transfer_hook = TransferHook { + authority: OptionalNonZeroPubkey::try_from(Some(context.payer.pubkey())).unwrap(), + program_id: OptionalNonZeroPubkey::try_from(Some(*hook_program_id)).unwrap(), + }; + + let pod_mint = PodMint { + mint_authority: COption::Some(context.payer.pubkey()).into(), + supply: 1_000_000u64.into(), + decimals: 6, + is_initialized: true.into(), + freeze_authority: COption::None.into(), + }; + *state.base = pod_mint; + + state + .init_account_type() + .expect("Failed to init account type"); + + context + .svm + .set_account( + *mint, + Account { + lamports: 1_000_000_000, + data, + owner: TOKEN_2022_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }, + ) + .expect("Failed to set Token 2022 mint account with TransferHook"); +} + pub fn create_mint_2022_with_transfer_fee( context: &mut TestContext, mint: &Keypair, diff --git a/docs/ESCROW_INTERACTION_GUIDE.md b/docs/ESCROW_INTERACTION_GUIDE.md index a74967bc..a63feecf 100644 --- a/docs/ESCROW_INTERACTION_GUIDE.md +++ b/docs/ESCROW_INTERACTION_GUIDE.md @@ -102,7 +102,8 @@ const allowMintIx = await getAllowMintInstructionAsync({ **Security Notes:** - Supports Token Program and Token-2022 -- Token-2022 mints with permanent delegate or pausable extensions are rejected +- Token-2022 mints with the `TransferHook` extension are rejected (the program's `TransferChecked` CPI does not resolve extra-account metas, so every deposit/release would fail on-chain) +- Token-2022 mints with `PermanentDelegate` or `PausableConfig` extensions are accepted; the operator enforces drain detection and pause state off-chain via a withdrawal pre-flight - Only the instance admin can allow mints - **Token-2022 usage**: The `tokenProgram` parameter defaults to the legacy Token Program. For Token-2022 mints, you must explicitly pass the Token-2022 program ID (e.g., `tokenProgram: TOKEN_2022_PROGRAM_ADDRESS`). This also applies to the `Deposit` instruction. diff --git a/docs/ESCROW_PROGRAM.md b/docs/ESCROW_PROGRAM.md index cac7f649..0c0f3682 100644 --- a/docs/ESCROW_PROGRAM.md +++ b/docs/ESCROW_PROGRAM.md @@ -289,14 +289,13 @@ The program defines the following custom errors: | 3 | `InvalidInstanceId` | Instance ID invalid or does not respect rules | | 4 | `InvalidInstance` | Invalid instance provided | | 5 | `InvalidAdmin` | Invalid admin provided | -| 6 | `PermanentDelegateNotAllowed` | Permanent delegate extension not allowed | -| 7 | `PausableMintNotAllowed` | Pausable mint extension not allowed | -| 8 | `InvalidOperatorPda` | Invalid operator PDA provided | -| 9 | `InvalidTokenAccount` | Invalid token account provided | -| 10 | `InvalidEscrowBalance` | Invalid escrow balance | -| 11 | `InvalidAllowedMint` | Invalid allowed mint | -| 12 | `InvalidSmtProof` | Invalid SMT proof provided | -| 13 | `InvalidTransactionNonceForCurrentTreeIndex` | Invalid transaction nonce for current tree index | +| 6 | `TransferHookNotAllowed` | Transfer hook extension not allowed | +| 7 | `InvalidOperatorPda` | Invalid operator PDA provided | +| 8 | `InvalidTokenAccount` | Invalid token account provided | +| 9 | `InvalidEscrowBalance` | Invalid escrow balance | +| 10 | `InvalidAllowedMint` | Invalid allowed mint | +| 11 | `InvalidSmtProof` | Invalid SMT proof provided | +| 12 | `InvalidTransactionNonceForCurrentTreeIndex` | Invalid transaction nonce for current tree index | ## Other Constants diff --git a/indexer/src/indexer/transaction_processor.rs b/indexer/src/indexer/transaction_processor.rs index 1fc1c142..9565406b 100644 --- a/indexer/src/indexer/transaction_processor.rs +++ b/indexer/src/indexer/transaction_processor.rs @@ -420,6 +420,10 @@ mod tests { let mint = mint.unwrap(); assert_eq!(mint.mint_address, make_pubkey(2).to_string()); assert_eq!(mint.decimals, 6); + // The indexer leaves Token-2022 extension resolution to the operator — + // both flags must stay None at AllowMint time. + assert_eq!(mint.is_pausable, None); + assert_eq!(mint.has_permanent_delegate, None); } #[test] diff --git a/indexer/src/operator/processor.rs b/indexer/src/operator/processor.rs index 27684eae..8d70eebc 100644 --- a/indexer/src/operator/processor.rs +++ b/indexer/src/operator/processor.rs @@ -423,6 +423,80 @@ fn build_scheduled_rotation( TransactionBuilder::ResetSmtRoot(Box::new(rotation_builder)) } +/// Token-2022 pre-flight for a withdrawal. +/// +/// Returns: +/// - `Ok(None)` — clean: proceed to build + dispatch. +/// - `Ok(Some(reason))` — row-specific bail: caller routes to ManualReview +/// via `quarantine_single` and continues the loop. Used for paused mints +/// and permanent-delegate drains where the row's data is fine but the +/// on-chain state would cause an immediate release-funds failure. +/// - `Err(_)` — transient infrastructure issue (RPC failure, malformed +/// mint data). Caller's classifier treats as Transient and restarts the +/// task, which is preferable to mass-quarantining rows during an RPC +/// blip. +async fn check_withdrawal_preflights( + processor_state: &mut ProcessorState, + transaction: &DbTransaction, +) -> Result, OperatorError> { + let mint = Pubkey::from_str(&transaction.mint).map_err(|e| OperatorError::InvalidPubkey { + pubkey: transaction.mint.clone(), + reason: e.to_string(), + })?; + + // PausableConfig and PermanentDelegate only exist on Token-2022 mints. + // For legacy SPL Token, skip the pre-flight entirely — saves an RPC + // round-trip on every withdrawal and avoids forcing extension-flag + // resolution for mints that can't carry the extensions in the first + // place. Falls back to RPC only if the mint isn't in the DB yet. + let token_program = processor_state + .mint_cache + .get_mint_metadata(&mint) + .await? + .token_program; + if token_program != spl_token_2022::ID { + return Ok(None); + } + + let (is_pausable, has_permanent_delegate) = processor_state + .mint_cache + .get_extension_flags(&mint) + .await?; + + if is_pausable && processor_state.mint_cache.check_paused(&mint).await? { + return Ok(Some(format!("mint paused: {mint}"))); + } + + if has_permanent_delegate { + let amount = u64::try_from(transaction.amount).map_err(|_| { + OperatorError::Program(ProgramError::InvalidBuilder { + reason: format!( + "negative withdrawal amount {} for transaction {}", + transaction.amount, transaction.id + ), + }) + })?; + + let release_funds_state = processor_state + .release_funds_state + .as_mut() + .ok_or(OperatorError::MissingBuilder)?; + let instance_ata = release_funds_state.get_instance_ata(&mint, &token_program); + + let on_chain = processor_state + .mint_cache + .get_ata_balance(&instance_ata) + .await?; + if on_chain < amount { + return Ok(Some(format!( + "insufficient escrow balance: on_chain={on_chain}, needed={amount}" + ))); + } + } + + Ok(None) +} + pub async fn process_release_funds( processor_state: &mut ProcessorState, mut fetcher_rx: mpsc::Receiver, @@ -441,10 +515,39 @@ pub async fn process_release_funds( let span = info_span!("process", trace_id = %transaction.trace_id, txn_id = transaction.id); let outcome: Result<(), OperatorError> = async { - // Build the withdrawal first so rotation + withdrawal dispatch are - // atomic from the sender's perspective. + // Build the withdrawal first so (a) rotation + withdrawal dispatch + // are atomic from the sender's perspective, and (b) row-data + // poison (e.g. NULL nonce, unparseable pubkey) surfaces here as + // an `InvalidBuilder` for the classifier to halt the pipeline on. + // Build also warms `MintCache.cache`, so the pre-flight below + // doesn't pay an extra DB/RPC round-trip for `get_mint_metadata`. let release_funds_tx = build_release_funds(processor_state, &transaction).await?; + // Pre-flight checks for Token-2022 extension state. Pause and + // permanent-delegate-drain are row-specific: the mint or its + // on-chain balance is the issue, but other withdrawals are + // unaffected. Bails route to ManualReview via `quarantine_single` + // and continue the loop — they intentionally do NOT trigger + // `halt_withdrawal_pipeline` (which is reserved for poison-pill + // rows that would corrupt the SMT). + // + // The pre-flight is best-effort, not a guarantee: a permanent + // delegate can drain the escrow ATA between this balance read and + // the on-chain `TransferChecked` CPI. In that race the CPI fails + // on-chain and the row is handled by the normal sender + // confirmation / retry path — the pre-flight just shrinks the + // window in the common case. + // + // RPC errors during pre-flight bubble up via `?` and are + // classified as Transient by `classify_processor_error`, + // restarting the task. That's preferred over flooding the alert + // stream with ManualReview entries while RPC flaps. + if let Some(reason) = check_withdrawal_preflights(processor_state, &transaction).await? + { + quarantine_single(&storage_tx, &transaction, reason).await; + return Ok(()); + } + // Scheduled rotation (normal path): when a nonce lands on the // MAX_TREE_LEAVES boundary, rotate the tree BEFORE dispatching the // boundary withdrawal. @@ -781,6 +884,8 @@ mod tests { decimals: 6, token_program: spl_token::id().to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }, ); } @@ -846,6 +951,8 @@ mod tests { decimals: 6, token_program: spl_token::id().to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }, ); } @@ -962,7 +1069,7 @@ mod tests { let mut ps = ProcessorState { admin_pubkey: Pubkey::new_unique(), release_funds_state: None, - mint_cache: crate::operator::MintCache::new(storage), + mint_cache: crate::operator::MintCache::new(storage.clone()), }; let mint_pubkey = Pubkey::new_unique(); @@ -1010,7 +1117,7 @@ mod tests { let mut ps = ProcessorState { admin_pubkey: Pubkey::new_unique(), release_funds_state: None, - mint_cache: crate::operator::MintCache::new(storage), + mint_cache: crate::operator::MintCache::new(storage.clone()), }; let (fetcher_tx, fetcher_rx) = mpsc::channel::(1); @@ -1056,7 +1163,7 @@ mod tests { let mut ps = ProcessorState { admin_pubkey: Pubkey::new_unique(), release_funds_state: None, - mint_cache: crate::operator::MintCache::new(storage), + mint_cache: crate::operator::MintCache::new(storage.clone()), }; let (fetcher_tx, fetcher_rx) = mpsc::channel::(1); @@ -1091,7 +1198,7 @@ mod tests { let mut ps = ProcessorState { admin_pubkey: Pubkey::new_unique(), release_funds_state: None, - mint_cache: crate::operator::MintCache::new(storage), + mint_cache: crate::operator::MintCache::new(storage.clone()), }; let (fetcher_tx, fetcher_rx) = mpsc::channel::(1); @@ -1152,6 +1259,8 @@ mod tests { decimals: 6, token_program: spl_token::id().to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }, ); } @@ -1493,6 +1602,8 @@ mod tests { decimals: 6, token_program: spl_token::id().to_string(), created_at: chrono::Utc::now(), + is_pausable: None, + has_permanent_delegate: None, }, ); } @@ -1619,6 +1730,8 @@ mod tests { decimals: 6, token_program: spl_token::id().to_string(), created_at: chrono::Utc::now(), + is_pausable: None, + has_permanent_delegate: None, }, ); } @@ -1945,4 +2058,202 @@ mod tests { "no sender-side dispatch expected on halt" ); } + + /// When a mint carries the PermanentDelegate extension and the escrow ATA + /// balance is below the withdrawal amount, the withdrawal must be routed to + /// ManualReview via `storage_tx` (no TransactionBuilder emitted). + #[tokio::test] + async fn process_release_funds_permanent_delegate_insufficient_balance_routes_to_manual_review() + { + use crate::operator::rpc_util::RpcClientWithRetry; + use solana_client::rpc_request::RpcRequest; + + let mint_pubkey = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + + let mock = MockStorage::new(); + mock.mints.lock().unwrap().insert( + mint_pubkey.to_string(), + crate::storage::common::models::DbMint { + mint_address: mint_pubkey.to_string(), + decimals: 6, + token_program: spl_token_2022::id().to_string(), + created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(true), + }, + ); + let storage = Arc::new(Storage::Mock(mock)); + + // On-chain balance < amount → should bail to ManualReview. + let balance_response = serde_json::json!({ + "context": {"slot": 1}, + "value": { + "amount": "500", + "decimals": 6, + "uiAmount": 0.0005, + "uiAmountString": "0.0005" + } + }); + let mut mocks = std::collections::HashMap::new(); + mocks.insert(RpcRequest::GetTokenAccountBalance, balance_response); + let rpc_client = RpcClientWithRetry::new_mocked(mocks); + + let (storage_tx, mut storage_rx) = mpsc::channel(1); + + let mut ps = ProcessorState { + admin_pubkey: Pubkey::new_unique(), + release_funds_state: Some(make_release_funds_state()), + mint_cache: crate::operator::MintCache::with_rpc(storage.clone(), Arc::new(rpc_client)), + }; + + let (fetcher_tx, fetcher_rx) = mpsc::channel::(1); + let (sender_tx, mut sender_rx) = mpsc::channel(10); + + let txn = DbTransaction { + id: 42, + signature: "test_sig".to_string(), + trace_id: "trace-42".to_string(), + slot: 100, + initiator: "initiator".to_string(), + recipient: recipient.to_string(), + mint: mint_pubkey.to_string(), + amount: 1000, // > on-chain balance of 500 + memo: None, + transaction_type: crate::storage::common::models::TransactionType::Withdrawal, + withdrawal_nonce: Some(5), + status: crate::storage::common::models::TransactionStatus::Processing, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + processed_at: None, + counterpart_signature: None, + remint_signatures: None, + pending_remint_deadline_at: None, + }; + + fetcher_tx.send(txn).await.unwrap(); + drop(fetcher_tx); + + process_release_funds( + &mut ps, + fetcher_rx, + sender_tx, + storage_tx, + storage, + crate::config::ProgramType::Withdraw, + ) + .await + .unwrap(); + + let update = storage_rx + .try_recv() + .expect("ManualReview status update should have been sent"); + assert_eq!(update.transaction_id, 42); + assert_eq!(update.status, TransactionStatus::ManualReview); + let err_msg = update.error_message.expect("error_message must be set"); + assert!( + err_msg.contains("insufficient escrow balance") + && err_msg.contains("on_chain=500") + && err_msg.contains("needed=1000"), + "unexpected error_message: {err_msg}", + ); + assert!( + sender_rx.try_recv().is_err(), + "no TransactionBuilder should have been emitted", + ); + } + + /// When the escrow ATA balance is sufficient, the permanent-delegate + /// pre-flight is a no-op and the withdrawal proceeds to the sender. + #[tokio::test] + async fn process_release_funds_permanent_delegate_sufficient_balance_proceeds() { + use crate::operator::rpc_util::RpcClientWithRetry; + use solana_client::rpc_request::RpcRequest; + + let mint_pubkey = Pubkey::new_unique(); + let recipient = Pubkey::new_unique(); + + let mock = MockStorage::new(); + mock.mints.lock().unwrap().insert( + mint_pubkey.to_string(), + crate::storage::common::models::DbMint { + mint_address: mint_pubkey.to_string(), + decimals: 6, + token_program: spl_token_2022::id().to_string(), + created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(true), + }, + ); + let storage = Arc::new(Storage::Mock(mock)); + + let balance_response = serde_json::json!({ + "context": {"slot": 1}, + "value": { + "amount": "5000", + "decimals": 6, + "uiAmount": 0.005, + "uiAmountString": "0.005" + } + }); + let mut mocks = std::collections::HashMap::new(); + mocks.insert(RpcRequest::GetTokenAccountBalance, balance_response); + let rpc_client = RpcClientWithRetry::new_mocked(mocks); + + let (storage_tx, mut storage_rx) = mpsc::channel(1); + + let mut ps = ProcessorState { + admin_pubkey: Pubkey::new_unique(), + release_funds_state: Some(make_release_funds_state()), + mint_cache: crate::operator::MintCache::with_rpc(storage.clone(), Arc::new(rpc_client)), + }; + + let (fetcher_tx, fetcher_rx) = mpsc::channel::(1); + let (sender_tx, mut sender_rx) = mpsc::channel(10); + + let txn = DbTransaction { + id: 7, + signature: "test_sig".to_string(), + trace_id: "trace-7".to_string(), + slot: 100, + initiator: "initiator".to_string(), + recipient: recipient.to_string(), + mint: mint_pubkey.to_string(), + amount: 1000, // < on-chain balance of 5000 + memo: None, + transaction_type: crate::storage::common::models::TransactionType::Withdrawal, + withdrawal_nonce: Some(5), + status: crate::storage::common::models::TransactionStatus::Processing, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + processed_at: None, + counterpart_signature: None, + remint_signatures: None, + pending_remint_deadline_at: None, + }; + + fetcher_tx.send(txn).await.unwrap(); + drop(fetcher_tx); + + process_release_funds( + &mut ps, + fetcher_rx, + sender_tx, + storage_tx, + storage, + crate::config::ProgramType::Withdraw, + ) + .await + .unwrap(); + + let msg = sender_rx.recv().await.expect("ReleaseFunds should be sent"); + let TransactionBuilder::ReleaseFunds(b) = msg else { + panic!("expected ReleaseFunds, got a different variant"); + }; + assert_eq!(b.transaction_id, 7); + assert!( + storage_rx.try_recv().is_err(), + "no ManualReview update should have been sent", + ); + } } diff --git a/indexer/src/operator/utils/mint_util.rs b/indexer/src/operator/utils/mint_util.rs index 98a84cda..96c1aad5 100644 --- a/indexer/src/operator/utils/mint_util.rs +++ b/indexer/src/operator/utils/mint_util.rs @@ -1,25 +1,53 @@ -use crate::error::{AccountError, OperatorError, StorageError}; +use crate::error::{AccountError, OperatorError}; use crate::operator::RpcClientWithRetry; use crate::storage::Storage; +use solana_rpc_client_api::client_error; +use solana_rpc_client_api::client_error::ErrorKind; +use solana_rpc_client_api::request::RpcError; use solana_sdk::pubkey::Pubkey; use spl_token::ID as TOKEN_PROGRAM_ID; +use spl_token_2022::extension::{ + pausable::PausableConfig, permanent_delegate::PermanentDelegate, BaseStateWithExtensions, + StateWithExtensions, +}; +use spl_token_2022::state::Mint as Token2022MintState; use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; +use tracing::warn; const DECIMALS_OFFSET: usize = 44; -/// In-memory cache for mint metadata (token_program and decimals) -/// Fetches from storage once and caches for subsequent lookups -/// Falls back to on-chain RPC if not in storage +/// `getTokenAccountBalance` returns `RpcResponseError { code: -32602, ... }` +/// when the ATA does not exist. The lowercased substring match is a fallback +/// for non-standard RPC providers that may surface the same condition with a +/// different code. +fn is_account_not_found(e: &client_error::Error) -> bool { + let ErrorKind::RpcError(RpcError::RpcResponseError { code, message, .. }) = &e.kind else { + return false; + }; + if *code == -32602 { + return true; + } + let msg = message.to_lowercase(); + msg.contains("could not find account") || msg.contains("account not found") +} + +/// In-memory cache for basic mint metadata (`token_program`, `decimals`). +/// Token-2022 extension flags (`is_pausable`, `has_permanent_delegate`) are +/// resolved separately via [`MintCache::get_extension_flags`], because the +/// deposit-side sender JIT-init path has a `MintCache` pointed at the +/// **Contra** RPC where the mint doesn't yet exist — forcing extension +/// resolution from `get_mint_metadata` made that path fail with +/// `AccountNotFound` and broke every fresh deposit. pub struct MintCache { storage: Arc, rpc_client: Option>, cache: HashMap, + extension_flags_cache: HashMap, } -/// Cached mint metadata #[derive(Clone, Debug, PartialEq)] pub struct MintMetadata { pub token_program: Pubkey, @@ -32,6 +60,7 @@ impl MintCache { storage, rpc_client: None, cache: HashMap::new(), + extension_flags_cache: HashMap::new(), } } @@ -40,64 +69,186 @@ impl MintCache { storage, rpc_client: Some(rpc_client), cache: HashMap::new(), + extension_flags_cache: HashMap::new(), } } - /// Get mint metadata from cache or fetch from storage - /// Falls back to RPC if not in storage + /// Basic mint metadata (decimals + token program). Cache → DB → RPC + /// fallback only when no DB row exists. + /// + /// Opportunistically warms `extension_flags_cache` on both the DB-hit + /// and RPC-fallback branches: the DB row already carries the flags if + /// they've been resolved before, and the RPC fallback's + /// `fetch_mint_from_rpc` returns them as a by-product of parsing the + /// mint account. This saves the subsequent `get_extension_flags` call + /// on the withdrawal pre-flight a second DB round-trip (or, on the + /// RPC-fallback path, a second RPC parse of the same mint). pub async fn get_mint_metadata( &mut self, mint: &Pubkey, ) -> Result { let mint_str = mint.to_string(); - // Check cache first if let Some(metadata) = self.cache.get(&mint_str) { return Ok(metadata.clone()); } - // Try storage - if let Some(db_mint) = self.storage.get_mint(&mint_str).await? { - let token_program = Pubkey::from_str(&db_mint.token_program).map_err(|e| { - OperatorError::InvalidPubkey { - pubkey: db_mint.token_program.clone(), + if let Some(m) = self.storage.get_mint(&mint_str).await? { + let token_program = + Pubkey::from_str(&m.token_program).map_err(|e| OperatorError::InvalidPubkey { + pubkey: m.token_program.clone(), reason: e.to_string(), - } - })?; - + })?; let metadata = MintMetadata { token_program, - decimals: db_mint.decimals as u8, + decimals: m.decimals as u8, }; - - self.cache.insert(mint_str, metadata.clone()); + self.cache.insert(mint_str.clone(), metadata.clone()); + if let (Some(p), Some(d)) = (m.is_pausable, m.has_permanent_delegate) { + self.extension_flags_cache.insert(mint_str, (p, d)); + } return Ok(metadata); } - // Fallback to RPC if available - if let Some(rpc) = &self.rpc_client { - let metadata = self.fetch_mint_from_rpc(mint, rpc).await?; - self.cache.insert(mint_str, metadata.clone()); - return Ok(metadata); + let rpc = self.rpc_client.as_ref().ok_or_else(|| { + OperatorError::RpcError(format!( + "MintCache needs RPC for unknown mint {mint_str}, but no RPC client is configured", + )) + })?; + + let (metadata, flags) = self.fetch_mint_from_rpc(mint, rpc).await?; + self.cache.insert(mint_str.clone(), metadata.clone()); + self.extension_flags_cache.insert(mint_str, flags); + Ok(metadata) + } + + /// Returns `(is_pausable, has_permanent_delegate)` for the mint. + /// Cache → DB (if both flags resolved) → RPC + write-back. Used by the + /// withdraw pre-flight; the deposit path never calls this. + pub async fn get_extension_flags( + &mut self, + mint: &Pubkey, + ) -> Result<(bool, bool), OperatorError> { + let mint_str = mint.to_string(); + + if let Some(flags) = self.extension_flags_cache.get(&mint_str) { + return Ok(*flags); + } + + let db_mint = self.storage.get_mint(&mint_str).await?; + if let Some(ref m) = db_mint { + if let (Some(p), Some(d)) = (m.is_pausable, m.has_permanent_delegate) { + self.extension_flags_cache.insert(mint_str, (p, d)); + return Ok((p, d)); + } + } + + let rpc = self.rpc_client.as_ref().ok_or_else(|| { + OperatorError::RpcError(format!( + "MintCache needs RPC to resolve extension flags for mint {mint_str}", + )) + })?; + + let (_metadata, flags) = self.fetch_mint_from_rpc(mint, rpc).await?; + + // Write-back only when the indexer has already landed a row. No row + // means this is a pre-AllowMint-ingested edge case; we keep the + // resolution in-memory and let the indexer's upsert land. + // + // Write-back failure is logged but not propagated: the in-memory + // flags are authoritative for this process's lifetime, and a + // transient DB blip would otherwise escalate a healthy withdrawal + // to ManualReview via the caller's bail path. A later restart will + // naturally retry the write-back on the next RPC fetch. + if db_mint.is_some() { + if let Err(e) = self + .storage + .set_mint_extension_flags(&mint_str, flags.0, flags.1) + .await + { + warn!( + mint = %mint_str, error = %e, + "extension-flag write-back failed; continuing with in-memory resolution", + ); + } } - Err(StorageError::DatabaseError { - message: format!("Mint not found in storage: {}", mint_str), + self.extension_flags_cache.insert(mint_str, flags); + Ok(flags) + } + + /// Live check of the `PausableConfig.paused` flag. Intended for the + /// pre-flight pause check in the operator's ReleaseFunds path: only + /// call this after `MintMetadata.is_pausable` came back true. + pub async fn check_paused(&self, mint: &Pubkey) -> Result { + let rpc = self.rpc_client.as_ref().ok_or_else(|| { + OperatorError::RpcError("check_paused requires an RPC client".to_string()) + })?; + + let account = rpc + .get_account(mint) + .await + .map_err(|_| AccountError::AccountNotFound { pubkey: *mint })?; + + let state = + StateWithExtensions::::unpack(&account.data).map_err(|_| { + AccountError::InvalidMint { + pubkey: *mint, + reason: "failed to parse Token-2022 mint".to_string(), + } + })?; + + let cfg = + state + .get_extension::() + .map_err(|_| AccountError::InvalidMint { + pubkey: *mint, + reason: "mint is tagged is_pausable but PausableConfig extension is missing" + .to_string(), + })?; + + Ok(bool::from(cfg.paused)) + } + + /// Live fetch of a token account's raw balance (base units). + /// + /// Intended for the permanent-delegate pre-flight: we can't trust our + /// indexed balance because a permanent delegate may have moved tokens + /// out of the escrow ATA without emitting a Contra program event. Only + /// call this after `MintMetadata.has_permanent_delegate` came back true. + pub async fn get_ata_balance(&self, ata: &Pubkey) -> Result { + let rpc = self.rpc_client.as_ref().ok_or_else(|| { + OperatorError::RpcError("get_ata_balance requires an RPC client".to_string()) + })?; + + // A non-existent ATA is semantically a zero balance — return Ok(0) + // so the caller can compare it against the expected amount. Mapping + // the not-found error to RpcError would classify it as Transient + // and restart the operator forever on a condition that won't heal. + match rpc.get_token_account_balance(ata).await { + Ok(ui_amount) => ui_amount.amount.parse::().map_err(|e| { + OperatorError::RpcError(format!( + "failed to parse token balance '{}' for {ata}: {e}", + ui_amount.amount + )) + }), + Err(e) if is_account_not_found(&e) => Ok(0), + Err(e) => Err(OperatorError::RpcError(format!( + "get_token_account_balance({ata}): {e}" + ))), } - .into()) } async fn fetch_mint_from_rpc( &self, mint: &Pubkey, rpc: &RpcClientWithRetry, - ) -> Result { + ) -> Result<(MintMetadata, (bool, bool)), OperatorError> { let account = rpc .get_account(mint) .await .map_err(|_| AccountError::AccountNotFound { pubkey: *mint })?; - // Determine token program from account owner let token_program = account.owner; if ![TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].contains(&token_program) { @@ -108,8 +259,8 @@ impl MintCache { .into()); } - // Parse SPL token mint data directly (decimals is at offset 44 for both SPL and T22) - // Mint layout: [option(coption_authority): 36 bytes, supply: 8 bytes, decimals: 1 byte, ...] + // Mint layout: [option(coption_authority): 36 bytes, supply: 8 bytes, + // decimals: 1 byte, ...]. Offset 44 works for both SPL and T22. if account.data.len() < DECIMALS_OFFSET + 1 { return Err(AccountError::InvalidMint { pubkey: *mint, @@ -120,10 +271,33 @@ impl MintCache { let decimals = account.data[DECIMALS_OFFSET]; - Ok(MintMetadata { - token_program, - decimals, - }) + // PausableConfig and PermanentDelegate can only exist on Token-2022. + // For a Token-2022-owned account that fails to parse we surface + // InvalidMint rather than silently caching `(false, false)`: the + // latter would poison the DB row and permanently bypass the pause + // and drain pre-flights for that mint. + let mut is_pausable = false; + let mut has_permanent_delegate = false; + if token_program == TOKEN_2022_PROGRAM_ID { + let m = + StateWithExtensions::::unpack(&account.data).map_err(|_| { + AccountError::InvalidMint { + pubkey: *mint, + reason: "failed to parse Token-2022 mint for extension detection" + .to_string(), + } + })?; + is_pausable = m.get_extension::().is_ok(); + has_permanent_delegate = m.get_extension::().is_ok(); + } + + Ok(( + MintMetadata { + token_program, + decimals, + }, + (is_pausable, has_permanent_delegate), + )) } /// Pre-populate cache with mint metadata @@ -178,8 +352,12 @@ mod tests { } fn create_mock_mint_account_data(decimals: u8) -> Vec { + // Base SPL Mint layout (82 bytes). is_initialized sits at offset 45 — + // must be 1 so Token-2022 `StateWithExtensions::unpack` accepts the + // account; otherwise the parser surfaces UninitializedAccount. let mut data = vec![0u8; 82]; data[DECIMALS_OFFSET] = decimals; + data[45] = 1; data } @@ -215,6 +393,8 @@ mod tests { decimals, token_program: token_program.to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }); Arc::new(Storage::Mock(mock)) @@ -279,6 +459,8 @@ mod tests { decimals: 6, token_program: TOKEN_PROGRAM_ID.to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }); } @@ -307,12 +489,16 @@ mod tests { decimals: 6, token_program: TOKEN_PROGRAM_ID.to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }); mock.add_mint(DbMint { mint_address: t22_mint.to_string(), decimals: 9, token_program: TOKEN_2022_PROGRAM_ID.to_string(), created_at: chrono::Utc::now(), + is_pausable: Some(false), + has_permanent_delegate: Some(false), }); let storage = Arc::new(Storage::Mock(mock)); @@ -368,6 +554,164 @@ mod tests { assert_eq!(metadata.decimals, 6); } + #[tokio::test] + async fn get_extension_flags_resolves_via_rpc_and_writes_back_when_db_flags_unresolved() { + let mint = create_test_mint(); + + // Indexer has landed the mints row but the operator hasn't resolved + // the extension flags yet — this is the state we lazily fill. + let mock_storage = MockStorage::new(); + mock_storage.mints.lock().unwrap().insert( + mint.to_string(), + DbMint { + mint_address: mint.to_string(), + decimals: 6, + token_program: TOKEN_PROGRAM_ID.to_string(), + created_at: chrono::Utc::now(), + is_pausable: None, + has_permanent_delegate: None, + }, + ); + + // Plain SPL Token mint on RPC → no extensions → both flags false. + let account_response = create_mock_account_response(&TOKEN_PROGRAM_ID, 6); + let mut mocks = std::collections::HashMap::new(); + mocks.insert(RpcRequest::GetAccountInfo, account_response); + let rpc_client = RpcClientWithRetry::new_mocked(mocks); + + let storage = Arc::new(Storage::Mock(mock_storage.clone())); + let mut cache = MintCache::with_rpc(storage, Arc::new(rpc_client)); + + let (is_pausable, has_permanent_delegate) = cache.get_extension_flags(&mint).await.unwrap(); + assert!(!is_pausable); + assert!(!has_permanent_delegate); + + // Write-back happened — subsequent reads don't need RPC. + let stored = mock_storage + .mints + .lock() + .unwrap() + .get(&mint.to_string()) + .cloned() + .expect("mint row should still exist after write-back"); + assert_eq!(stored.is_pausable, Some(false)); + assert_eq!(stored.has_permanent_delegate, Some(false)); + } + + #[tokio::test] + async fn get_mint_metadata_does_not_require_rpc_when_db_flags_are_unresolved() { + let mint = create_test_mint(); + + // DB row has flags = None. Pre-fix, `get_mint_metadata` would force + // RPC resolution and fail here (breaking JIT-mint init on the + // deposit path, where the mint-cache RPC can't see the mint yet). + // Post-fix, `get_mint_metadata` is pure decimals + token_program — + // flags are resolved separately via `get_extension_flags`. + let mock_storage = MockStorage::new(); + mock_storage.mints.lock().unwrap().insert( + mint.to_string(), + DbMint { + mint_address: mint.to_string(), + decimals: 6, + token_program: TOKEN_PROGRAM_ID.to_string(), + created_at: chrono::Utc::now(), + is_pausable: None, + has_permanent_delegate: None, + }, + ); + + let storage = Arc::new(Storage::Mock(mock_storage)); + let mut cache = MintCache::new(storage); + + let metadata = cache.get_mint_metadata(&mint).await.unwrap(); + assert_eq!(metadata.token_program, TOKEN_PROGRAM_ID); + assert_eq!(metadata.decimals, 6); + } + + #[tokio::test] + async fn get_extension_flags_errors_when_unresolved_and_no_rpc() { + let mint = create_test_mint(); + + let mock_storage = MockStorage::new(); + mock_storage.mints.lock().unwrap().insert( + mint.to_string(), + DbMint { + mint_address: mint.to_string(), + decimals: 6, + token_program: TOKEN_PROGRAM_ID.to_string(), + created_at: chrono::Utc::now(), + is_pausable: None, + has_permanent_delegate: None, + }, + ); + + let storage = Arc::new(Storage::Mock(mock_storage)); + let mut cache = MintCache::new(storage); + + let err = cache + .get_extension_flags(&mint) + .await + .expect_err("should error without RPC"); + assert!( + matches!(err, crate::error::OperatorError::RpcError(_)), + "expected RpcError, got {err:?}", + ); + } + + #[tokio::test] + async fn get_ata_balance_errors_without_rpc() { + let storage = Arc::new(Storage::Mock(MockStorage::new())); + let cache = MintCache::new(storage); + + let err = cache + .get_ata_balance(&create_test_mint()) + .await + .expect_err("get_ata_balance should require RPC"); + assert!( + matches!(err, crate::error::OperatorError::RpcError(_)), + "expected RpcError, got {err:?}", + ); + } + + #[tokio::test] + async fn get_ata_balance_parses_amount_from_rpc() { + let ata = Pubkey::new_unique(); + let balance_response = serde_json::json!({ + "context": {"slot": 1}, + "value": { + "amount": "123456789", + "decimals": 6, + "uiAmount": 123.456789, + "uiAmountString": "123.456789" + } + }); + + let mut mocks = std::collections::HashMap::new(); + mocks.insert(RpcRequest::GetTokenAccountBalance, balance_response); + let rpc_client = RpcClientWithRetry::new_mocked(mocks); + + let storage = Arc::new(Storage::Mock(MockStorage::new())); + let cache = MintCache::with_rpc(storage, Arc::new(rpc_client)); + + let balance = cache.get_ata_balance(&ata).await.unwrap(); + assert_eq!(balance, 123_456_789); + } + + #[tokio::test] + async fn check_paused_errors_without_rpc() { + let storage = Arc::new(Storage::Mock(MockStorage::new())); + let cache = MintCache::new(storage); + + let err = cache + .check_paused(&create_test_mint()) + .await + .expect_err("check_paused should require RPC"); + assert!( + matches!(err, crate::error::OperatorError::RpcError(_)), + "expected RpcError, got {err:?}", + ); + } + #[tokio::test] async fn test_rpc_fallback_invalid_owner() { let mint = create_test_mint(); diff --git a/indexer/src/operator/utils/rpc_util.rs b/indexer/src/operator/utils/rpc_util.rs index cf3dd097..5e847658 100644 --- a/indexer/src/operator/utils/rpc_util.rs +++ b/indexer/src/operator/utils/rpc_util.rs @@ -178,6 +178,20 @@ impl RpcClientWithRetry { .await } + /// Get token account balance with retry (read-only, safe to retry) + pub async fn get_token_account_balance( + &self, + pubkey: &Pubkey, + ) -> Result> + { + self.with_retry( + "get_token_account_balance", + RetryPolicy::Idempotent, + || async { self.rpc_client.get_token_account_balance(pubkey).await }, + ) + .await + } + /// Get signature statuses with retry (read-only, always safe to retry) pub async fn get_signature_statuses( &self, diff --git a/indexer/src/operator/utils/transaction_util.rs b/indexer/src/operator/utils/transaction_util.rs index eb86484c..1b4b2fd9 100644 --- a/indexer/src/operator/utils/transaction_util.rs +++ b/indexer/src/operator/utils/transaction_util.rs @@ -198,8 +198,8 @@ pub fn parse_program_error( InstructionError::Custom(code), ) => { match *code { - 12 => Some(ContraEscrowProgramError::InvalidSmtProof), - 13 => Some(ContraEscrowProgramError::InvalidTransactionNonceForCurrentTreeIndex), + 11 => Some(ContraEscrowProgramError::InvalidSmtProof), + 12 => Some(ContraEscrowProgramError::InvalidTransactionNonceForCurrentTreeIndex), _ => None, // Ignore other program errors } } @@ -289,8 +289,8 @@ mod tests { // ==================================================================== #[test] - fn parse_custom_12_invalid_smt_proof() { - let err = TransactionError::InstructionError(0, InstructionError::Custom(12)); + fn parse_custom_11_invalid_smt_proof() { + let err = TransactionError::InstructionError(0, InstructionError::Custom(11)); let result = parse_program_error(&err); assert!(matches!( result, @@ -299,8 +299,8 @@ mod tests { } #[test] - fn parse_custom_13_invalid_nonce() { - let err = TransactionError::InstructionError(0, InstructionError::Custom(13)); + fn parse_custom_12_invalid_nonce() { + let err = TransactionError::InstructionError(0, InstructionError::Custom(12)); let result = parse_program_error(&err); assert!(matches!( result, @@ -389,7 +389,7 @@ mod tests { assert!(matches!(result, Ok(ConfirmationResult::Confirmed))); } - /// A confirmed status carrying Custom(12) must decode to Failed(InvalidSmtProof) so + /// A confirmed status carrying Custom(11) must decode to Failed(InvalidSmtProof) so /// the sender receives the exact escrow-program error rather than a generic failure. #[tokio::test] async fn check_transaction_status_returns_failed_on_program_error() { @@ -409,9 +409,9 @@ mod tests { "value": [{ "confirmationStatus": "confirmed", "confirmations": 1, - "err": {"InstructionError": [0, {"Custom": 12}]}, + "err": {"InstructionError": [0, {"Custom": 11}]}, "slot": 100, - "status": {"Err": {"InstructionError": [0, {"Custom": 12}]}} + "status": {"Err": {"InstructionError": [0, {"Custom": 11}]}} }] } }) diff --git a/indexer/src/storage/common/models.rs b/indexer/src/storage/common/models.rs index ea4f6f7e..a1e6729b 100644 --- a/indexer/src/storage/common/models.rs +++ b/indexer/src/storage/common/models.rs @@ -95,6 +95,12 @@ pub struct DbMint { pub decimals: i16, pub token_program: String, pub created_at: DateTime, + /// `None` = the on-chain PausableConfig extension state is unknown to us yet. + /// Resolved lazily by the operator's MintCache on first RPC fetch. + pub is_pausable: Option, + /// `None` = the on-chain PermanentDelegate extension state is unknown to us yet. + /// Resolved lazily alongside `is_pausable` in a single RPC fetch. + pub has_permanent_delegate: Option, } impl DbMint { @@ -104,6 +110,8 @@ impl DbMint { decimals, token_program, created_at: Utc::now(), + is_pausable: None, + has_permanent_delegate: None, } } } diff --git a/indexer/src/storage/common/storage.rs b/indexer/src/storage/common/storage.rs index 35c9eacc..4b0451b6 100644 --- a/indexer/src/storage/common/storage.rs +++ b/indexer/src/storage/common/storage.rs @@ -16,6 +16,7 @@ pub mod init_schema; pub mod insert_db_transaction; pub mod insert_db_transactions_batch; pub mod quarantine_all_active_withdrawals; +pub mod set_mint_extension_flags; pub mod set_pending_remint; pub mod update_committed_checkpoint; pub mod update_transaction_status; @@ -143,6 +144,24 @@ impl Storage { get_mint::get_mint(self, mint_address).await } + /// Write-back the on-chain extension presence (PausableConfig, + /// PermanentDelegate) for a mint. Called by the operator's MintCache + /// after a single RPC fetch that resolves both flags together. + pub async fn set_mint_extension_flags( + &self, + mint_address: &str, + is_pausable: bool, + has_permanent_delegate: bool, + ) -> Result<(), StorageError> { + set_mint_extension_flags::set_mint_extension_flags( + self, + mint_address, + is_pausable, + has_permanent_delegate, + ) + .await + } + /// Return per-mint aggregate balances (completed deposits minus withdrawals) for startup reconciliation. pub async fn get_mint_balances_for_reconciliation( &self, diff --git a/indexer/src/storage/common/storage/mock.rs b/indexer/src/storage/common/storage/mock.rs index c883c707..a195bf53 100644 --- a/indexer/src/storage/common/storage/mock.rs +++ b/indexer/src/storage/common/storage/mock.rs @@ -199,7 +199,24 @@ impl MockStorage { self.check_should_fail("upsert_mints_batch")?; let mut store = self.mints.lock().unwrap(); for mint in mints { - store.insert(mint.mint_address.clone(), mint.clone()); + // Must mirror the Postgres `ON CONFLICT DO UPDATE SET decimals, + // token_program` semantics: the indexer upserts a `DbMint::new` + // (flags = None) every time it sees AllowMint, but the operator + // lazily fills `is_pausable` / `has_permanent_delegate` via + // `set_mint_extension_flags`. A re-upsert (reorg, indexer + // restart, retry) must preserve those flags, otherwise the next + // withdrawal wastes an RPC round-trip re-resolving them. A + // blanket `insert` here would silently disagree with prod and + // let tests lock in the wrong behavior. + match store.get_mut(&mint.mint_address) { + Some(existing) => { + existing.decimals = mint.decimals; + existing.token_program = mint.token_program.clone(); + } + None => { + store.insert(mint.mint_address.clone(), mint.clone()); + } + } } Ok(()) } @@ -208,6 +225,26 @@ impl MockStorage { Ok(self.mints.lock().unwrap().get(mint_address).cloned()) } + pub async fn set_mint_extension_flags( + &self, + mint_address: &str, + is_pausable: bool, + has_permanent_delegate: bool, + ) -> Result<(), StorageError> { + self.check_should_fail("set_mint_extension_flags")?; + let mut mints = self.mints.lock().unwrap(); + match mints.get_mut(mint_address) { + Some(mint) => { + mint.is_pausable = Some(is_pausable); + mint.has_permanent_delegate = Some(has_permanent_delegate); + Ok(()) + } + None => Err(StorageError::DatabaseError { + message: format!("set_mint_extension_flags: no mints row for {mint_address}"), + }), + } + } + pub fn set_mint_balances(&self, balances: Vec) { *self.mint_balances.lock().unwrap() = balances; } diff --git a/indexer/src/storage/common/storage/set_mint_extension_flags.rs b/indexer/src/storage/common/storage/set_mint_extension_flags.rs new file mode 100644 index 00000000..0837c98f --- /dev/null +++ b/indexer/src/storage/common/storage/set_mint_extension_flags.rs @@ -0,0 +1,21 @@ +use crate::{error::StorageError, storage::common::storage::Storage}; + +pub async fn set_mint_extension_flags( + storage: &Storage, + mint_address: &str, + is_pausable: bool, + has_permanent_delegate: bool, +) -> Result<(), StorageError> { + match storage { + Storage::Postgres(db) => { + db.set_mint_extension_flags_internal(mint_address, is_pausable, has_permanent_delegate) + .await + } + #[cfg(any(test, feature = "test-mock-storage"))] + Storage::Mock(mock_db) => { + mock_db + .set_mint_extension_flags(mint_address, is_pausable, has_permanent_delegate) + .await + } + } +} diff --git a/indexer/src/storage/postgres/db.rs b/indexer/src/storage/postgres/db.rs index 2ce438db..882e1169 100644 --- a/indexer/src/storage/postgres/db.rs +++ b/indexer/src/storage/postgres/db.rs @@ -327,6 +327,20 @@ impl PostgresDb { .execute(&self.pool) .await?; + // Idempotent migration: add is_pausable to existing databases. + // Nullable = "unknown"; populated lazily by the operator after an RPC + // check against the on-chain mint's Token-2022 PausableConfig extension. + sqlx::query("ALTER TABLE mints ADD COLUMN IF NOT EXISTS is_pausable BOOLEAN") + .execute(&self.pool) + .await?; + + // Same pattern for the PermanentDelegate extension — resolved lazily + // the first time the operator touches the mint. Gate for the balance + // pre-flight that guards against permanent-delegate drains. + sqlx::query("ALTER TABLE mints ADD COLUMN IF NOT EXISTS has_permanent_delegate BOOLEAN") + .execute(&self.pool) + .await?; + // Add failed_reminted status for withdrawal remint recovery sqlx::query( r#" @@ -919,6 +933,37 @@ impl PostgresDb { Ok(()) } + /// Write-back from the operator's MintCache after it resolves whether + /// the on-chain mint carries the Token-2022 PausableConfig and + /// PermanentDelegate extensions. Both flags are always resolved in the + /// same RPC fetch, so they're persisted together in a single update. + /// Errors if the row doesn't exist — the indexer always lands the + /// `mints` row before any withdrawal for that mint can reach the + /// operator, so a missing row indicates an ordering bug. + pub async fn set_mint_extension_flags_internal( + &self, + mint_address: &str, + is_pausable: bool, + has_permanent_delegate: bool, + ) -> Result<(), StorageError> { + let result = sqlx::query( + "UPDATE mints SET is_pausable = $2, has_permanent_delegate = $3 WHERE mint_address = $1", + ) + .bind(mint_address) + .bind(is_pausable) + .bind(has_permanent_delegate) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(StorageError::DatabaseError { + message: format!("set_mint_extension_flags: no mints row for {mint_address}"), + }); + } + + Ok(()) + } + pub async fn get_mint_internal( &self, mint_address: &str, diff --git a/indexer/tests/postgres_db_test.rs b/indexer/tests/postgres_db_test.rs index dccd509c..cbde139a 100644 --- a/indexer/tests/postgres_db_test.rs +++ b/indexer/tests/postgres_db_test.rs @@ -562,3 +562,44 @@ async fn set_pending_remint_fails_when_not_processing() -> Result<(), Box Result<(), Box> { + let (_pool, storage, _pg) = start_postgres().await?; + + let m = DbMint::new("mint_ext".to_string(), 6, "TokenkegQ".to_string()); + storage.upsert_mints_batch(&[m]).await?; + let row = storage.get_mint("mint_ext").await?.unwrap(); + assert_eq!(row.is_pausable, None, "upsert should not set is_pausable"); + assert_eq!( + row.has_permanent_delegate, None, + "upsert should not set has_permanent_delegate", + ); + + storage + .set_mint_extension_flags("mint_ext", true, false) + .await?; + let row = storage.get_mint("mint_ext").await?.unwrap(); + assert_eq!(row.is_pausable, Some(true)); + assert_eq!(row.has_permanent_delegate, Some(false)); + + // Idempotent — writing the same values again is fine. + storage + .set_mint_extension_flags("mint_ext", true, false) + .await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_mint_extension_flags_fails_when_no_row() -> Result<(), Box> { + let (_pool, storage, _pg) = start_postgres().await?; + + let result = storage + .set_mint_extension_flags("mint_never_upserted", true, false) + .await; + + assert!(result.is_err(), "should fail when mints row doesn't exist"); + Ok(()) +} diff --git a/integration/Cargo.toml b/integration/Cargo.toml index 95935ce9..9767d373 100644 --- a/integration/Cargo.toml +++ b/integration/Cargo.toml @@ -39,6 +39,14 @@ path = "tests/indexer/resync.rs" name = "reconciliation_e2e_test" path = "tests/indexer/reconciliation_e2e.rs" +[[test]] +name = "pausable_mint_integration" +path = "tests/indexer/pausable_mint.rs" + +[[test]] +name = "permanent_delegate_mint_integration" +path = "tests/indexer/permanent_delegate_mint.rs" + [[test]] name = "mock_rpc_retry" path = "tests/indexer/mock_rpc_retry.rs" diff --git a/integration/Makefile b/integration/Makefile index e7c96fc8..20f9d07f 100644 --- a/integration/Makefile +++ b/integration/Makefile @@ -45,6 +45,8 @@ integration-test: @cargo test --test mint_idempotency_integration -- --nocapture @cargo test --test gap_detection_integration -- --nocapture @cargo test --test truncate_integration -- --nocapture + @cargo test --test pausable_mint_integration -- --nocapture + @cargo test --test permanent_delegate_mint_integration -- --nocapture @cargo test --test resync_integration -- --nocapture @cargo test --test reconciliation_e2e_test -- --nocapture @cargo test --test mock_rpc_retry -- --nocapture @@ -234,6 +236,10 @@ integration-coverage-indexer: @cargo llvm-cov test --no-report --workspace --test gap_detection_integration -- --nocapture @echo "Running truncate integration tests with coverage instrumentation..." @cargo llvm-cov test --no-report --workspace --test truncate_integration -- --nocapture + @echo "Running pausable mint integration tests with coverage instrumentation..." + @cargo llvm-cov test --no-report --workspace --test pausable_mint_integration -- --nocapture + @echo "Running permanent delegate mint integration tests with coverage instrumentation..." + @cargo llvm-cov test --no-report --workspace --test permanent_delegate_mint_integration -- --nocapture @echo "Running resync integration tests with coverage instrumentation..." @cargo llvm-cov test --no-report --workspace --test resync_integration -- --nocapture @echo "Running reconciliation e2e tests with coverage instrumentation..." diff --git a/integration/tests/indexer/pausable_mint.rs b/integration/tests/indexer/pausable_mint.rs new file mode 100644 index 00000000..a4dba3ba --- /dev/null +++ b/integration/tests/indexer/pausable_mint.rs @@ -0,0 +1,431 @@ +//! Integration test for the Token-2022 PausableConfig pre-flight on the +//! withdrawal operator. +//! +//! Scenario: +//! 1. Create a Token-2022 mint with the PausableConfig extension initialized +//! (unpaused at creation), AllowMint it on the escrow instance, and fund +//! the escrow ATA so a withdrawal has tokens to release. +//! 2. Pause the mint on-chain. +//! 3. Seed a `DbMint` row with `is_pausable = None` (the state the indexer +//! leaves behind at AllowMint time) and a pending `DbTransaction` +//! withdrawal at nonce 0. +//! 4. Start the Contra→Solana withdrawal operator. +//! 5. Assert the operator routes the withdrawal to `manual_review`: the row +//! status flips from `pending` to `manual_review`, and `mints.is_pausable` +//! flips from `None` to `Some(true)` (lazy RPC resolution + write-back). +//! No tokens are released to the recipient. The webhook alert payload is +//! covered by unit tests in `db_transaction_writer`; here we just assert +//! the terminal DB state the operator bailed into. + +#[path = "helpers/mod.rs"] +mod helpers; + +#[allow(dead_code)] +#[path = "setup.rs"] +mod setup; + +use chrono::Utc; +use contra_escrow_program_client::{instructions::AllowMintBuilder, CONTRA_ESCROW_PROGRAM_ID}; +use contra_indexer::storage::common::models::{ + DbMint, DbTransaction, TransactionStatus, TransactionType, +}; +use contra_indexer::storage::{PostgresDb, Storage}; +use contra_indexer::PostgresConfig; +use helpers::db; +use setup::{find_allowed_mint_pda, find_event_authority_pda, TestEnvironment, TEST_ADMIN_KEYPAIR}; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_sdk::commitment_config::CommitmentConfig; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::{Keypair, Signature, Signer}; +use solana_sdk::transaction::Transaction; +use solana_system_interface::{instruction::create_account, program::ID as SYSTEM_PROGRAM_ID}; +use spl_associated_token_account::{ + get_associated_token_address_with_program_id, + instruction::create_associated_token_account_idempotent, +}; +use spl_token_2022::extension::{pausable, ExtensionType}; +use spl_token_2022::state::Mint as Token2022Mint; +use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; +use std::time::Duration; +use test_utils::operator_helper::start_contra_to_solana_operator; +use test_utils::validator_helper::start_test_validator_no_geyser; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::postgres::Postgres; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Local helpers — Token-2022 mint with PausableConfig +// --------------------------------------------------------------------------- + +/// Create a Token-2022 mint on-chain with the PausableConfig extension. +/// `authority` is the mint authority AND the pause authority. +async fn generate_pausable_mint_2022( + client: &RpcClient, + payer: &Keypair, + authority: &Keypair, + mint: &Keypair, +) -> Result> { + let space = + ExtensionType::try_calculate_account_len::(&[ExtensionType::Pausable])?; + let rent = client.get_minimum_balance_for_rent_exemption(space).await?; + + // Three-instruction sequence: allocate, init extension, init mint. + // Extensions must be initialized BEFORE the mint itself. + let ixs = vec![ + create_account( + &payer.pubkey(), + &mint.pubkey(), + rent, + space as u64, + &TOKEN_2022_PROGRAM_ID, + ), + pausable::instruction::initialize( + &TOKEN_2022_PROGRAM_ID, + &mint.pubkey(), + &authority.pubkey(), + )?, + spl_token_2022::instruction::initialize_mint2( + &TOKEN_2022_PROGRAM_ID, + &mint.pubkey(), + &authority.pubkey(), + Some(&authority.pubkey()), + 6, + )?, + ]; + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&payer.pubkey()), + &[payer, mint], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + + Ok(mint.pubkey()) +} + +/// Mint Token-2022 tokens to `owner`, creating their ATA if needed. +async fn mint_2022_to_owner( + client: &RpcClient, + payer: &Keypair, + mint: Pubkey, + owner: Pubkey, + authority: &Keypair, + amount: u64, +) -> Result> { + let ata = get_associated_token_address_with_program_id(&owner, &mint, &TOKEN_2022_PROGRAM_ID); + + let ixs = vec![ + create_associated_token_account_idempotent( + &payer.pubkey(), + &owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ), + spl_token_2022::instruction::mint_to( + &TOKEN_2022_PROGRAM_ID, + &mint, + &ata, + &authority.pubkey(), + &[], + amount, + )?, + ]; + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&payer.pubkey()), + &[payer, authority], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + + Ok(ata) +} + +async fn set_mint_paused( + client: &RpcClient, + payer: &Keypair, + authority: &Keypair, + mint: &Pubkey, + paused: bool, +) -> Result<(), Box> { + let ix = if paused { + pausable::instruction::pause(&TOKEN_2022_PROGRAM_ID, mint, &authority.pubkey(), &[])? + } else { + pausable::instruction::resume(&TOKEN_2022_PROGRAM_ID, mint, &authority.pubkey(), &[])? + }; + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer, authority], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + Ok(()) +} + +/// Allow a mint on the escrow instance, binding it to `token_program`. Unlike +/// the shared `TestEnvironment::setup` which hardcodes SPL Token, this one +/// accepts any token program — needed for Token-2022. +async fn allow_mint_for_program( + client: &RpcClient, + admin: &Keypair, + instance: Pubkey, + mint: Pubkey, + token_program: Pubkey, +) -> Result<(), Box> { + let (allowed_mint_pda, bump) = find_allowed_mint_pda(&instance, &mint); + let (event_authority_pda, _) = find_event_authority_pda(); + let instance_ata = + get_associated_token_address_with_program_id(&instance, &mint, &token_program); + + let ix = AllowMintBuilder::new() + .payer(admin.pubkey()) + .admin(admin.pubkey()) + .instance(instance) + .mint(mint) + .allowed_mint(allowed_mint_pda) + .instance_ata(instance_ata) + .system_program(SYSTEM_PROGRAM_ID) + .token_program(token_program) + .associated_token_program(spl_associated_token_account::ID) + .event_authority(event_authority_pda) + .contra_escrow_program(CONTRA_ESCROW_PROGRAM_ID) + .bump(bump) + .instruction(); + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&admin.pubkey()), + &[admin], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + Ok(()) +} + +fn make_withdrawal_transaction( + signature: String, + mint: String, + recipient: String, + amount: i64, + nonce: i64, +) -> DbTransaction { + let now = Utc::now(); + DbTransaction { + id: 0, + signature, + trace_id: Uuid::new_v4().to_string(), + slot: 1, + initiator: recipient.clone(), + recipient, + mint, + amount, + memo: None, + transaction_type: TransactionType::Withdrawal, + withdrawal_nonce: Some(nonce), + status: TransactionStatus::Pending, + created_at: now, + updated_at: now, + processed_at: None, + counterpart_signature: None, + remint_signatures: None, + pending_remint_deadline_at: None, + } +} + +async fn get_token_2022_balance( + client: &RpcClient, + owner: &Pubkey, + mint: &Pubkey, +) -> Result> { + let ata = get_associated_token_address_with_program_id(owner, mint, &TOKEN_2022_PROGRAM_ID); + match client.get_token_account_balance(&ata).await { + Ok(bal) => Ok(bal.amount.parse::()?), + // ATA may not exist yet before release-funds fires it into existence. + Err(_) => Ok(0), + } +} + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn test_withdrawal_routed_to_manual_review_when_pausable_mint_is_paused( +) -> Result<(), Box> { + println!("=== Pausable Mint: Withdrawal → ManualReview While Paused ==="); + + set_operator_env_vars(); + + let (test_validator, faucet_keypair) = start_test_validator_no_geyser().await; + let client = + RpcClient::new_with_commitment(test_validator.rpc_url(), CommitmentConfig::confirmed()); + + let pg_container = Postgres::default() + .with_db_name("pausable_mint") + .with_user("postgres") + .with_password("password") + .start() + .await?; + let pg_host = pg_container.get_host().await?; + let pg_port = pg_container.get_host_port_ipv4(5432).await?; + let db_url = format!( + "postgres://postgres:password@{}:{}/pausable_mint", + pg_host, pg_port + ); + + let pool = db::connect(&db_url).await?; + let storage = Storage::Postgres( + PostgresDb::new(&PostgresConfig { + database_url: db_url.clone(), + max_connections: 10, + }) + .await?, + ); + storage.init_schema().await?; + + // Instance + operator (reuses shared setup — pure escrow state, no mint). + let admin = Keypair::try_from(&TEST_ADMIN_KEYPAIR[..])?; + let recipient = Keypair::new(); + + let (_, instance_pda) = TestEnvironment::setup_instance(&client, &faucet_keypair, None).await?; + TestEnvironment::setup_operator(&client, &faucet_keypair, instance_pda).await?; + + // Pausable Token-2022 mint. Admin is both mint and pause authority. + let mint_keypair = Keypair::new(); + let mint_pubkey = generate_pausable_mint_2022(&client, &admin, &admin, &mint_keypair).await?; + println!("Created pausable Token-2022 mint {}", mint_pubkey); + + // AllowMint on the escrow — this would fail before our program-side change. + allow_mint_for_program( + &client, + &admin, + instance_pda, + mint_pubkey, + TOKEN_2022_PROGRAM_ID, + ) + .await?; + println!("AllowMint succeeded for pausable mint"); + + // Fund the escrow ATA directly. Sidesteps the deposit path — only the + // escrow ATA balance matters for release_funds. + let withdraw_amount: u64 = 50_000; + mint_2022_to_owner( + &client, + &admin, + mint_pubkey, + instance_pda, + &admin, + withdraw_amount * 2, + ) + .await?; + + // release_funds requires the recipient ATA to already exist — the escrow + // program's `validate_ata` rejects empty-data ATAs. Pre-create it here + // by minting zero tokens (ATA idempotent creation + mint_to amount=0). + mint_2022_to_owner(&client, &admin, mint_pubkey, recipient.pubkey(), &admin, 0).await?; + + // Pause the mint BEFORE the operator sees the withdrawal. + set_mint_paused(&client, &admin, &admin, &mint_pubkey, true).await?; + println!("Mint paused on-chain"); + + // Seed DB: mints row with is_pausable=None (what the indexer would write + // at AllowMint time), and a pending withdrawal at nonce 0. + let mint_meta = DbMint::new( + mint_pubkey.to_string(), + 6, + TOKEN_2022_PROGRAM_ID.to_string(), + ); + storage.upsert_mints_batch(&[mint_meta]).await?; + assert!( + storage + .get_mint(&mint_pubkey.to_string()) + .await? + .expect("mints row") + .is_pausable + .is_none(), + "pre-condition: DB mints row should have is_pausable = None", + ); + + let withdrawal_sig = Signature::new_unique().to_string(); + let withdrawal_tx = make_withdrawal_transaction( + withdrawal_sig.clone(), + mint_pubkey.to_string(), + recipient.pubkey().to_string(), + withdraw_amount as i64, + 0, + ); + storage.insert_db_transaction(&withdrawal_tx).await?; + + // Start the withdraw operator. + let operator_handle = start_contra_to_solana_operator( + test_validator.rpc_url(), + db_url.clone(), + Keypair::try_from(&TEST_ADMIN_KEYPAIR[..])?, + instance_pda, + ) + .await?; + + // Mint is paused → pre-flight must route the withdrawal to manual_review + // (terminal status; no self-recovery on unpause). + let deadline = std::time::Instant::now() + Duration::from_secs(60); + loop { + if let Some(tx) = db::get_transaction(&pool, &withdrawal_sig).await? { + if tx.status == "manual_review" { + break; + } + } + if std::time::Instant::now() >= deadline { + return Err(format!( + "withdrawal {} did not reach manual_review within 60s", + withdrawal_sig + ) + .into()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let row = db::get_transaction(&pool, &withdrawal_sig) + .await? + .expect("withdrawal row should still exist"); + assert_eq!( + row.status, "manual_review", + "paused mint should route the withdrawal to manual_review", + ); + + let stored_mint = storage + .get_mint(&mint_pubkey.to_string()) + .await? + .expect("mints row"); + assert_eq!( + stored_mint.is_pausable, + Some(true), + "operator should have resolved is_pausable via RPC and written it back", + ); + + let recipient_balance = + get_token_2022_balance(&client, &recipient.pubkey(), &mint_pubkey).await?; + assert_eq!( + recipient_balance, 0, + "recipient ATA should be empty — no release_funds should have landed", + ); + + operator_handle.shutdown().await; + Ok(()) +} + +fn set_operator_env_vars() { + let admin = Keypair::try_from(&TEST_ADMIN_KEYPAIR[..]).expect("valid admin keypair"); + let private_key_base58 = bs58::encode(admin.to_bytes()).into_string(); + std::env::set_var("ADMIN_SIGNER", "memory"); + std::env::set_var("ADMIN_PRIVATE_KEY", &private_key_base58); + std::env::set_var("OPERATOR_SIGNER", "memory"); + std::env::set_var("OPERATOR_PRIVATE_KEY", &private_key_base58); +} diff --git a/integration/tests/indexer/permanent_delegate_mint.rs b/integration/tests/indexer/permanent_delegate_mint.rs new file mode 100644 index 00000000..4e622555 --- /dev/null +++ b/integration/tests/indexer/permanent_delegate_mint.rs @@ -0,0 +1,645 @@ +//! Integration test for the Token-2022 PermanentDelegate pre-flight on the +//! withdrawal operator. +//! +//! Scenario — the attack the pre-flight exists to block: +//! 1. Create a Token-2022 mint with the PermanentDelegate extension; the +//! delegate is a keypair we control. AllowMint it on the escrow instance. +//! 2. Fund the escrow ATA with 2x the withdrawal amount via `mint_to`. This +//! is the only way the escrow balance gets bumped in this test — sidesteps +//! the deposit path so the operator has no Contra-side event for the +//! drain that follows. +//! 3. Use the permanent delegate to drain the escrow ATA below the withdrawal +//! amount. The escrow program is never invoked, so the indexer sees +//! nothing and the DB's implied balance (still 2x) diverges from on-chain. +//! 4. Seed the DB: `mints` row with `has_permanent_delegate = None` (what the +//! indexer writes at AllowMint time) and a pending withdrawal for the full +//! amount at nonce 0. +//! 5. Start the Contra→Solana withdrawal operator. +//! 6. Assert the operator routes the withdrawal to `manual_review`: the row +//! status flips from `pending` to `manual_review`, `has_permanent_delegate` +//! flips from `None` to `Some(true)` (lazy RPC resolution + write-back), +//! and no tokens reach the recipient. Webhook firing is covered by the +//! db_transaction_writer unit tests. + +#[path = "helpers/mod.rs"] +mod helpers; + +#[allow(dead_code)] +#[path = "setup.rs"] +mod setup; + +use chrono::Utc; +use contra_escrow_program_client::{instructions::AllowMintBuilder, CONTRA_ESCROW_PROGRAM_ID}; +use contra_indexer::storage::common::models::{ + DbMint, DbTransaction, TransactionStatus, TransactionType, +}; +use contra_indexer::storage::{PostgresDb, Storage}; +use contra_indexer::PostgresConfig; +use helpers::db; +use setup::{find_allowed_mint_pda, find_event_authority_pda, TestEnvironment, TEST_ADMIN_KEYPAIR}; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_sdk::commitment_config::CommitmentConfig; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::{Keypair, Signature, Signer}; +use solana_sdk::transaction::Transaction; +use solana_system_interface::{instruction::create_account, program::ID as SYSTEM_PROGRAM_ID}; +use spl_associated_token_account::{ + get_associated_token_address_with_program_id, + instruction::create_associated_token_account_idempotent, +}; +use spl_token_2022::extension::ExtensionType; +use spl_token_2022::state::Mint as Token2022Mint; +use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; +use std::time::Duration; +use test_utils::operator_helper::start_contra_to_solana_operator; +use test_utils::validator_helper::start_test_validator_no_geyser; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::postgres::Postgres; +use uuid::Uuid; + +const MINT_DECIMALS: u8 = 6; + +// --------------------------------------------------------------------------- +// Local helpers — Token-2022 mint with PermanentDelegate + delegate-driven drain +// --------------------------------------------------------------------------- + +/// Create a Token-2022 mint on-chain with the PermanentDelegate extension. +/// The returned mint has `delegate` as its permanent delegate; the delegate +/// can transfer out of any ATA for this mint without the owner's consent. +async fn generate_permanent_delegate_mint_2022( + client: &RpcClient, + payer: &Keypair, + authority: &Keypair, + delegate: &Pubkey, + mint: &Keypair, +) -> Result> { + let space = ExtensionType::try_calculate_account_len::(&[ + ExtensionType::PermanentDelegate, + ])?; + let rent = client.get_minimum_balance_for_rent_exemption(space).await?; + + // Three-instruction sequence: allocate, init extension, init mint. + // Extensions must be initialized BEFORE the mint itself. + let ixs = vec![ + create_account( + &payer.pubkey(), + &mint.pubkey(), + rent, + space as u64, + &TOKEN_2022_PROGRAM_ID, + ), + spl_token_2022::instruction::initialize_permanent_delegate( + &TOKEN_2022_PROGRAM_ID, + &mint.pubkey(), + delegate, + )?, + spl_token_2022::instruction::initialize_mint2( + &TOKEN_2022_PROGRAM_ID, + &mint.pubkey(), + &authority.pubkey(), + Some(&authority.pubkey()), + MINT_DECIMALS, + )?, + ]; + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&payer.pubkey()), + &[payer, mint], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + + Ok(mint.pubkey()) +} + +/// Mint Token-2022 tokens to `owner`, creating their ATA if needed. +async fn mint_2022_to_owner( + client: &RpcClient, + payer: &Keypair, + mint: Pubkey, + owner: Pubkey, + authority: &Keypair, + amount: u64, +) -> Result> { + let ata = get_associated_token_address_with_program_id(&owner, &mint, &TOKEN_2022_PROGRAM_ID); + + let ixs = vec![ + create_associated_token_account_idempotent( + &payer.pubkey(), + &owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ), + spl_token_2022::instruction::mint_to( + &TOKEN_2022_PROGRAM_ID, + &mint, + &ata, + &authority.pubkey(), + &[], + amount, + )?, + ]; + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&payer.pubkey()), + &[payer, authority], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + + Ok(ata) +} + +/// Use the permanent delegate to move tokens out of `source_ata` into a +/// fresh ATA owned by `drain_owner`. Simulates the attack the pre-flight +/// is designed to catch: the escrow program is never invoked, so no +/// Contra-side event ever reaches the indexer. +async fn drain_via_permanent_delegate( + client: &RpcClient, + payer: &Keypair, + mint: Pubkey, + source_ata: Pubkey, + delegate: &Keypair, + drain_owner: Pubkey, + amount: u64, +) -> Result<(), Box> { + let drain_ata = + get_associated_token_address_with_program_id(&drain_owner, &mint, &TOKEN_2022_PROGRAM_ID); + + let ixs = vec![ + create_associated_token_account_idempotent( + &payer.pubkey(), + &drain_owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ), + spl_token_2022::instruction::transfer_checked( + &TOKEN_2022_PROGRAM_ID, + &source_ata, + &mint, + &drain_ata, + &delegate.pubkey(), + &[], + amount, + MINT_DECIMALS, + )?, + ]; + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&payer.pubkey()), + &[payer, delegate], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + Ok(()) +} + +/// Allow a mint on the escrow instance, binding it to `token_program`. The +/// shared `TestEnvironment::setup` hardcodes SPL Token, so we replicate the +/// AllowMint call here against Token-2022. +async fn allow_mint_for_program( + client: &RpcClient, + admin: &Keypair, + instance: Pubkey, + mint: Pubkey, + token_program: Pubkey, +) -> Result<(), Box> { + let (allowed_mint_pda, bump) = find_allowed_mint_pda(&instance, &mint); + let (event_authority_pda, _) = find_event_authority_pda(); + let instance_ata = + get_associated_token_address_with_program_id(&instance, &mint, &token_program); + + let ix = AllowMintBuilder::new() + .payer(admin.pubkey()) + .admin(admin.pubkey()) + .instance(instance) + .mint(mint) + .allowed_mint(allowed_mint_pda) + .instance_ata(instance_ata) + .system_program(SYSTEM_PROGRAM_ID) + .token_program(token_program) + .associated_token_program(spl_associated_token_account::ID) + .event_authority(event_authority_pda) + .contra_escrow_program(CONTRA_ESCROW_PROGRAM_ID) + .bump(bump) + .instruction(); + + let recent_blockhash = client.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&admin.pubkey()), + &[admin], + recent_blockhash, + ); + client.send_and_confirm_transaction(&tx).await?; + Ok(()) +} + +fn make_withdrawal_transaction( + signature: String, + mint: String, + recipient: String, + amount: i64, + nonce: i64, +) -> DbTransaction { + let now = Utc::now(); + DbTransaction { + id: 0, + signature, + trace_id: Uuid::new_v4().to_string(), + slot: 1, + initiator: recipient.clone(), + recipient, + mint, + amount, + memo: None, + transaction_type: TransactionType::Withdrawal, + withdrawal_nonce: Some(nonce), + status: TransactionStatus::Pending, + created_at: now, + updated_at: now, + processed_at: None, + counterpart_signature: None, + remint_signatures: None, + pending_remint_deadline_at: None, + } +} + +async fn get_token_2022_balance( + client: &RpcClient, + owner: &Pubkey, + mint: &Pubkey, +) -> Result> { + let ata = get_associated_token_address_with_program_id(owner, mint, &TOKEN_2022_PROGRAM_ID); + match client.get_token_account_balance(&ata).await { + Ok(bal) => Ok(bal.amount.parse::()?), + // ATA may not exist yet before release-funds fires it into existence. + Err(_) => Ok(0), + } +} + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn test_withdrawal_routed_to_manual_review_when_permanent_delegate_drained_escrow( +) -> Result<(), Box> { + println!("=== Permanent Delegate: Withdrawal → ManualReview When Escrow Drained ==="); + + set_operator_env_vars(); + + let (test_validator, faucet_keypair) = start_test_validator_no_geyser().await; + let client = + RpcClient::new_with_commitment(test_validator.rpc_url(), CommitmentConfig::confirmed()); + + let pg_container = Postgres::default() + .with_db_name("permanent_delegate_mint") + .with_user("postgres") + .with_password("password") + .start() + .await?; + let pg_host = pg_container.get_host().await?; + let pg_port = pg_container.get_host_port_ipv4(5432).await?; + let db_url = format!( + "postgres://postgres:password@{}:{}/permanent_delegate_mint", + pg_host, pg_port + ); + + let pool = db::connect(&db_url).await?; + let storage = Storage::Postgres( + PostgresDb::new(&PostgresConfig { + database_url: db_url.clone(), + max_connections: 10, + }) + .await?, + ); + storage.init_schema().await?; + + // Instance + operator (reuses shared setup — pure escrow state, no mint). + let admin = Keypair::try_from(&TEST_ADMIN_KEYPAIR[..])?; + let recipient = Keypair::new(); + let delegate = Keypair::new(); + let drainer = Keypair::new(); + + let (_, instance_pda) = TestEnvironment::setup_instance(&client, &faucet_keypair, None).await?; + TestEnvironment::setup_operator(&client, &faucet_keypair, instance_pda).await?; + + // Fund the delegate so it can pay tx fees when draining. + let fund_delegate_ix = solana_system_interface::instruction::transfer( + &faucet_keypair.pubkey(), + &delegate.pubkey(), + 1_000_000_000, + ); + let bh = client.get_latest_blockhash().await?; + let fund_tx = Transaction::new_signed_with_payer( + &[fund_delegate_ix], + Some(&faucet_keypair.pubkey()), + &[&faucet_keypair], + bh, + ); + client.send_and_confirm_transaction(&fund_tx).await?; + + // Token-2022 mint with PermanentDelegate. Admin is mint authority; a + // separate keypair holds the permanent-delegate authority. + let mint_keypair = Keypair::new(); + let mint_pubkey = generate_permanent_delegate_mint_2022( + &client, + &admin, + &admin, + &delegate.pubkey(), + &mint_keypair, + ) + .await?; + println!("Created permanent-delegate Token-2022 mint {}", mint_pubkey); + + // AllowMint on the escrow instance — accepted post-change. + allow_mint_for_program( + &client, + &admin, + instance_pda, + mint_pubkey, + TOKEN_2022_PROGRAM_ID, + ) + .await?; + println!("AllowMint succeeded for permanent-delegate mint"); + + // Fund the escrow ATA with 2x the withdrawal amount. Sidesteps the + // deposit path so no Contra-side deposit event is ever produced. + let withdraw_amount: u64 = 50_000; + let escrow_ata = mint_2022_to_owner( + &client, + &admin, + mint_pubkey, + instance_pda, + &admin, + withdraw_amount * 2, + ) + .await?; + + // release_funds requires the recipient ATA to already exist — the escrow + // program's `validate_ata` rejects empty-data ATAs. Pre-create it here + // by minting zero tokens to it. + mint_2022_to_owner(&client, &admin, mint_pubkey, recipient.pubkey(), &admin, 0).await?; + + // Drain the escrow ATA below the withdrawal amount using the permanent + // delegate. The escrow program is never invoked; the indexer sees + // nothing; the DB's derived balance remains at 2x the amount. + let drain_amount = withdraw_amount * 2 - (withdraw_amount / 2); // leave 25k, need 50k + drain_via_permanent_delegate( + &client, + &admin, + mint_pubkey, + escrow_ata, + &delegate, + drainer.pubkey(), + drain_amount, + ) + .await?; + println!( + "Permanent delegate drained {} tokens from the escrow ATA", + drain_amount + ); + + // Seed DB: mints row with has_permanent_delegate=None (what the indexer + // writes at AllowMint time), and a pending withdrawal at nonce 0. + let mint_meta = DbMint::new( + mint_pubkey.to_string(), + MINT_DECIMALS as i16, + TOKEN_2022_PROGRAM_ID.to_string(), + ); + storage.upsert_mints_batch(&[mint_meta]).await?; + let pre = storage + .get_mint(&mint_pubkey.to_string()) + .await? + .expect("mints row"); + assert!( + pre.has_permanent_delegate.is_none(), + "pre-condition: DB mints row should have has_permanent_delegate = None", + ); + + let withdrawal_sig = Signature::new_unique().to_string(); + let withdrawal_tx = make_withdrawal_transaction( + withdrawal_sig.clone(), + mint_pubkey.to_string(), + recipient.pubkey().to_string(), + withdraw_amount as i64, + 0, + ); + storage.insert_db_transaction(&withdrawal_tx).await?; + + // Start the withdraw operator. + let operator_handle = start_contra_to_solana_operator( + test_validator.rpc_url(), + db_url.clone(), + Keypair::try_from(&TEST_ADMIN_KEYPAIR[..])?, + instance_pda, + ) + .await?; + + // On-chain balance < withdrawal amount → pre-flight must route to + // manual_review (terminal; no self-recovery). + let deadline = std::time::Instant::now() + Duration::from_secs(60); + loop { + if let Some(tx) = db::get_transaction(&pool, &withdrawal_sig).await? { + if tx.status == "manual_review" { + break; + } + } + if std::time::Instant::now() >= deadline { + return Err(format!( + "withdrawal {} did not reach manual_review within 60s", + withdrawal_sig + ) + .into()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let row = db::get_transaction(&pool, &withdrawal_sig) + .await? + .expect("withdrawal row should still exist"); + assert_eq!( + row.status, "manual_review", + "drained escrow should route the withdrawal to manual_review", + ); + + let stored_mint = storage + .get_mint(&mint_pubkey.to_string()) + .await? + .expect("mints row"); + assert_eq!( + stored_mint.has_permanent_delegate, + Some(true), + "operator should have resolved has_permanent_delegate via RPC and written it back", + ); + + let recipient_balance = + get_token_2022_balance(&client, &recipient.pubkey(), &mint_pubkey).await?; + assert_eq!( + recipient_balance, 0, + "recipient ATA should be empty — no release_funds should have landed", + ); + + operator_handle.shutdown().await; + Ok(()) +} + +fn set_operator_env_vars() { + let admin = Keypair::try_from(&TEST_ADMIN_KEYPAIR[..]).expect("valid admin keypair"); + let private_key_base58 = bs58::encode(admin.to_bytes()).into_string(); + std::env::set_var("ADMIN_SIGNER", "memory"); + std::env::set_var("ADMIN_PRIVATE_KEY", &private_key_base58); + std::env::set_var("OPERATOR_SIGNER", "memory"); + std::env::set_var("OPERATOR_PRIVATE_KEY", &private_key_base58); +} + +/// Defensive coverage for the missing-ATA branch in the withdrawal pre-flight: +/// when the escrow ATA does not exist on-chain, the operator must treat the +/// query as on-chain balance = 0 and route the withdrawal to ManualReview. +/// Mapping the not-found error to a transient RPC failure instead would +/// restart the operator in a loop on a condition that won't heal. +/// +/// We skip `AllowMint` to set up the missing-ATA state — it's the simplest +/// way to leave the canonical escrow ATA address unfunded. The pre-flight +/// reads on-chain state at query time and doesn't care how we got there. +#[tokio::test(flavor = "multi_thread")] +async fn test_withdrawal_routed_to_manual_review_when_escrow_ata_does_not_exist( +) -> Result<(), Box> { + println!("=== Permanent Delegate: Withdrawal → ManualReview When Escrow ATA Missing ==="); + + set_operator_env_vars(); + + let (test_validator, faucet_keypair) = start_test_validator_no_geyser().await; + let client = + RpcClient::new_with_commitment(test_validator.rpc_url(), CommitmentConfig::confirmed()); + + let pg_container = Postgres::default() + .with_db_name("permanent_delegate_mint_missing_ata") + .with_user("postgres") + .with_password("password") + .start() + .await?; + let pg_host = pg_container.get_host().await?; + let pg_port = pg_container.get_host_port_ipv4(5432).await?; + let db_url = format!( + "postgres://postgres:password@{}:{}/permanent_delegate_mint_missing_ata", + pg_host, pg_port + ); + + let pool = db::connect(&db_url).await?; + let storage = Storage::Postgres( + PostgresDb::new(&PostgresConfig { + database_url: db_url.clone(), + max_connections: 10, + }) + .await?, + ); + storage.init_schema().await?; + + let admin = Keypair::try_from(&TEST_ADMIN_KEYPAIR[..])?; + let recipient = Keypair::new(); + let delegate = Keypair::new(); + + let (_, instance_pda) = TestEnvironment::setup_instance(&client, &faucet_keypair, None).await?; + TestEnvironment::setup_operator(&client, &faucet_keypair, instance_pda).await?; + + let mint_keypair = Keypair::new(); + let mint_pubkey = generate_permanent_delegate_mint_2022( + &client, + &admin, + &admin, + &delegate.pubkey(), + &mint_keypair, + ) + .await?; + + // Skip AllowMint so the escrow ATA is never created on-chain. + let escrow_ata = get_associated_token_address_with_program_id( + &instance_pda, + &mint_pubkey, + &TOKEN_2022_PROGRAM_ID, + ); + assert!( + client.get_account(&escrow_ata).await.is_err(), + "pre-condition: escrow ATA must not exist on-chain", + ); + + // Seed DB: mints row with has_permanent_delegate=None, pending withdrawal. + let mint_meta = DbMint::new( + mint_pubkey.to_string(), + MINT_DECIMALS as i16, + TOKEN_2022_PROGRAM_ID.to_string(), + ); + storage.upsert_mints_batch(&[mint_meta]).await?; + + let withdraw_amount: u64 = 50_000; + let withdrawal_sig = Signature::new_unique().to_string(); + let withdrawal_tx = make_withdrawal_transaction( + withdrawal_sig.clone(), + mint_pubkey.to_string(), + recipient.pubkey().to_string(), + withdraw_amount as i64, + 0, + ); + storage.insert_db_transaction(&withdrawal_tx).await?; + + let operator_handle = start_contra_to_solana_operator( + test_validator.rpc_url(), + db_url.clone(), + Keypair::try_from(&TEST_ADMIN_KEYPAIR[..])?, + instance_pda, + ) + .await?; + + let deadline = std::time::Instant::now() + Duration::from_secs(60); + loop { + if let Some(tx) = db::get_transaction(&pool, &withdrawal_sig).await? { + if tx.status == "manual_review" { + break; + } + } + if std::time::Instant::now() >= deadline { + return Err(format!( + "withdrawal {} did not reach manual_review within 60s", + withdrawal_sig + ) + .into()); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let row = db::get_transaction(&pool, &withdrawal_sig) + .await? + .expect("withdrawal row should still exist"); + assert_eq!( + row.status, "manual_review", + "missing escrow ATA should route the withdrawal to manual_review, not loop the operator", + ); + + let stored_mint = storage + .get_mint(&mint_pubkey.to_string()) + .await? + .expect("mints row"); + assert_eq!( + stored_mint.has_permanent_delegate, + Some(true), + "operator should have resolved has_permanent_delegate via RPC and written it back", + ); + + let recipient_balance = + get_token_2022_balance(&client, &recipient.pubkey(), &mint_pubkey).await?; + assert_eq!( + recipient_balance, 0, + "recipient ATA should be empty — no release_funds should have landed", + ); + + operator_handle.shutdown().await; + Ok(()) +}